diff --git a/plugins/common/opcua/client.go b/plugins/common/opcua/client.go index 5f1c2481d0c31..76c829c2e5da8 100644 --- a/plugins/common/opcua/client.go +++ b/plugins/common/opcua/client.go @@ -2,6 +2,7 @@ package opcua import ( "context" + "errors" "fmt" "log" //nolint:depguard // just for debug "net/url" @@ -184,7 +185,8 @@ type OpcUAClient struct { Config *OpcUAClientConfig Log telegraf.Logger - Client *opcua.Client + Client *opcua.Client + namespaceArray []string opts []opcua.Option codes []ua.StatusCode @@ -327,3 +329,53 @@ func (o *OpcUAClient) State() ConnectionState { } return ConnectionState(o.Client.State()) } + +// UpdateNamespaceArray fetches the namespace array from the OPC UA server +// The namespace array is stored at the well-known node ns=0;i=2255 +func (o *OpcUAClient) UpdateNamespaceArray(ctx context.Context) error { + if o.Client == nil { + return errors.New("client not connected") + } + + nodeID := ua.NewNumericNodeID(0, 2255) + req := &ua.ReadRequest{ + MaxAge: 2000, + NodesToRead: []*ua.ReadValueID{ + {NodeID: nodeID}, + }, + TimestampsToReturn: ua.TimestampsToReturnBoth, + } + + resp, err := o.Client.Read(ctx, req) + if err != nil { + return fmt.Errorf("failed to read namespace array: %w", err) + } + + if len(resp.Results) == 0 { + return errors.New("no results returned when reading namespace array") + } + + result := resp.Results[0] + if result.Status != ua.StatusOK { + return fmt.Errorf("failed to read namespace array, status: %w", result.Status) + } + + if result.Value == nil { + return errors.New("namespace array value is nil") + } + + // The namespace array is an array of strings + namespaces, ok := result.Value.Value().([]string) + if !ok { + return fmt.Errorf("namespace array is not a string array, got type: %T", result.Value.Value()) + } + + o.namespaceArray = namespaces + o.Log.Debugf("Fetched namespace array with %d entries", len(namespaces)) + return nil +} + +// NamespaceArray returns the cached namespace array +func (o *OpcUAClient) NamespaceArray() []string { + return o.namespaceArray +} diff --git a/plugins/common/opcua/input/input_client.go b/plugins/common/opcua/input/input_client.go index 79e3aac5d14cc..48d11ebb18037 100644 --- a/plugins/common/opcua/input/input_client.go +++ b/plugins/common/opcua/input/input_client.go @@ -51,6 +51,7 @@ type MonitoringParameters struct { type NodeSettings struct { FieldName string `toml:"name"` Namespace string `toml:"namespace"` + NamespaceURI string `toml:"namespace_uri"` IdentifierType string `toml:"identifier_type"` Identifier string `toml:"identifier"` DefaultTags map[string]string `toml:"default_tags"` @@ -59,6 +60,9 @@ type NodeSettings struct { // NodeID returns the OPC UA node id func (tag *NodeSettings) NodeID() string { + if tag.NamespaceURI != "" { + return "nsu=" + tag.NamespaceURI + ";" + tag.IdentifierType + "=" + tag.Identifier + } return "ns=" + tag.Namespace + ";" + tag.IdentifierType + "=" + tag.Identifier } @@ -66,6 +70,7 @@ func (tag *NodeSettings) NodeID() string { type NodeGroupSettings struct { MetricName string `toml:"name"` // Overrides plugin's setting Namespace string `toml:"namespace"` // Can be overridden by node setting + NamespaceURI string `toml:"namespace_uri"` // Can be overridden by node setting IdentifierType string `toml:"identifier_type"` // Can be overridden by node setting Nodes []NodeSettings `toml:"nodes"` DefaultTags map[string]string `toml:"default_tags"` @@ -74,11 +79,15 @@ type NodeGroupSettings struct { type EventNodeSettings struct { Namespace string `toml:"namespace"` + NamespaceURI string `toml:"namespace_uri"` IdentifierType string `toml:"identifier_type"` Identifier string `toml:"identifier"` } func (e *EventNodeSettings) NodeID() string { + if e.NamespaceURI != "" { + return "nsu=" + e.NamespaceURI + ";" + e.IdentifierType + "=" + e.Identifier + } return "ns=" + e.Namespace + ";" + e.IdentifierType + "=" + e.Identifier } @@ -87,6 +96,7 @@ type EventGroupSettings struct { QueueSize uint32 `toml:"queue_size"` EventTypeNode EventNodeSettings `toml:"event_type_node"` Namespace string `toml:"namespace"` + NamespaceURI string `toml:"namespace_uri"` IdentifierType string `toml:"identifier_type"` NodeIDSettings []EventNodeSettings `toml:"node_ids"` SourceNames []string `toml:"source_names"` @@ -99,6 +109,9 @@ func (e *EventGroupSettings) UpdateNodeIDSettings() { if n.Namespace == "" { n.Namespace = e.Namespace } + if n.NamespaceURI == "" { + n.NamespaceURI = e.NamespaceURI + } if n.IdentifierType == "" { n.IdentifierType = e.IdentifierType } @@ -138,11 +151,23 @@ func (e EventNodeSettings) validateEventNodeSettings() error { } if e.Identifier == "" { return errors.New("identifier must be set") - } else if e.IdentifierType == "" { + } + if e.IdentifierType == "" { return errors.New("identifier_type must be set") - } else if e.Namespace == "" { - return errors.New("namespace must be set") } + + // Validate namespace configuration + hasNamespace := len(e.Namespace) > 0 + hasNamespaceURI := len(e.NamespaceURI) > 0 + + if hasNamespace && hasNamespaceURI { + return errors.New("cannot specify both 'namespace' and 'namespace_uri', use only one") + } + + if !hasNamespace && !hasNamespaceURI { + return errors.New("must specify either 'namespace' or 'namespace_uri'") + } + return nil } @@ -334,8 +359,16 @@ func validateNodeToAdd(existing map[metricParts]struct{}, nmm *NodeMetricMapping return fmt.Errorf("empty name in %q", nmm.Tag.FieldName) } - if len(nmm.Tag.Namespace) == 0 { - return errors.New("empty node namespace not allowed") + // Validate namespace configuration + hasNamespace := len(nmm.Tag.Namespace) > 0 + hasNamespaceURI := len(nmm.Tag.NamespaceURI) > 0 + + if hasNamespace && hasNamespaceURI { + return fmt.Errorf("node %q: cannot specify both 'namespace' and 'namespace_uri', use only one", nmm.Tag.FieldName) + } + + if !hasNamespace && !hasNamespaceURI { + return fmt.Errorf("node %q: must specify either 'namespace' or 'namespace_uri'", nmm.Tag.FieldName) } if len(nmm.Tag.Identifier) == 0 { @@ -396,6 +429,9 @@ func (o *OpcUAInputClient) InitNodeMetricMapping() error { if node.Namespace == "" { node.Namespace = group.Namespace } + if node.NamespaceURI == "" { + node.NamespaceURI = group.NamespaceURI + } if node.IdentifierType == "" { node.IdentifierType = group.IdentifierType } @@ -420,29 +456,91 @@ func (o *OpcUAInputClient) InitNodeMetricMapping() error { func (o *OpcUAInputClient) InitNodeIDs() error { o.NodeIDs = make([]*ua.NodeID, 0, len(o.NodeMetricMapping)) + namespaceArray := o.NamespaceArray() + for _, node := range o.NodeMetricMapping { - nid, err := ua.ParseNodeID(node.Tag.NodeID()) - if err != nil { - return err + nodeIDStr := node.Tag.NodeID() + + // Check if this uses namespace URI (nsu=) format + if strings.HasPrefix(nodeIDStr, "nsu=") { + // Namespace URI format requires namespace array + if len(namespaceArray) == 0 { + return fmt.Errorf("node ID %q uses namespace URI (nsu=) but namespace array is not available - connection to server may be required", nodeIDStr) + } + // Use ParseExpandedNodeID for namespace URI support + expandedNodeID, err := ua.ParseExpandedNodeID(nodeIDStr, namespaceArray) + if err != nil { + return fmt.Errorf("failed to parse node ID %q: %w", nodeIDStr, err) + } + o.NodeIDs = append(o.NodeIDs, expandedNodeID.NodeID) + } else { + // Use ParseNodeID for namespace index (ns=) format + nid, err := ua.ParseNodeID(nodeIDStr) + if err != nil { + return fmt.Errorf("failed to parse node ID %q: %w", nodeIDStr, err) + } + o.NodeIDs = append(o.NodeIDs, nid) } - o.NodeIDs = append(o.NodeIDs, nid) } return nil } func (o *OpcUAInputClient) InitEventNodeIDs() error { + namespaceArray := o.NamespaceArray() + for _, eventSetting := range o.EventGroups { - eid, err := ua.ParseNodeID(eventSetting.EventTypeNode.NodeID()) - if err != nil { - return err + eventTypeNodeIDStr := eventSetting.EventTypeNode.NodeID() + var eid *ua.NodeID + + // Parse event type node ID + if strings.HasPrefix(eventTypeNodeIDStr, "nsu=") { + if len(namespaceArray) == 0 { + return fmt.Errorf( + "event type node ID %q uses namespace URI (nsu=) but namespace array is not available - "+ + "connection to server may be required", + eventTypeNodeIDStr, + ) + } + expandedNodeID, err := ua.ParseExpandedNodeID(eventTypeNodeIDStr, namespaceArray) + if err != nil { + return fmt.Errorf("failed to parse event type node ID %q: %w", eventTypeNodeIDStr, err) + } + eid = expandedNodeID.NodeID + } else { + parsedID, err := ua.ParseNodeID(eventTypeNodeIDStr) + if err != nil { + return fmt.Errorf("failed to parse event type node ID %q: %w", eventTypeNodeIDStr, err) + } + eid = parsedID } - for _, node := range eventSetting.NodeIDSettings { - nid, err := ua.ParseNodeID(node.NodeID()) - if err != nil { - return err + for _, node := range eventSetting.NodeIDSettings { + nodeIDStr := node.NodeID() + var nid *ua.NodeID + + // Parse node ID + if strings.HasPrefix(nodeIDStr, "nsu=") { + if len(namespaceArray) == 0 { + return fmt.Errorf( + "event node ID %q uses namespace URI (nsu=) but namespace array is not available - "+ + "connection to server may be required", + nodeIDStr, + ) + } + expandedNodeID, err := ua.ParseExpandedNodeID(nodeIDStr, namespaceArray) + if err != nil { + return fmt.Errorf("failed to parse node ID %q: %w", nodeIDStr, err) + } + nid = expandedNodeID.NodeID + } else { + parsedID, err := ua.ParseNodeID(nodeIDStr) + if err != nil { + return fmt.Errorf("failed to parse node ID %q: %w", nodeIDStr, err) + } + nid = parsedID } + nmm := EventNodeMetricMapping{ NodeID: nid, SamplingInterval: &eventSetting.SamplingInterval, diff --git a/plugins/common/opcua/input/input_client_test.go b/plugins/common/opcua/input/input_client_test.go index 4646859d684c7..98c88f5aaf006 100644 --- a/plugins/common/opcua/input/input_client_test.go +++ b/plugins/common/opcua/input/input_client_test.go @@ -338,7 +338,7 @@ func TestValidateNodeToAdd(t *testing.T) { require.NoError(t, err) return nmm }(), - err: errors.New("empty node namespace not allowed"), + err: errors.New("node \"f\": must specify either 'namespace' or 'namespace_uri'"), }, { name: "empty identifier type not allowed", @@ -793,3 +793,226 @@ func TestMetricForNode(t *testing.T) { }) } } + +// TestNodeIDGeneration tests that NodeID() generates correct node ID strings +func TestNodeIDGeneration(t *testing.T) { + tests := []struct { + name string + node NodeSettings + expected string + }{ + { + name: "namespace index format", + node: NodeSettings{ + Namespace: "3", + IdentifierType: "s", + Identifier: "Temperature", + }, + expected: "ns=3;s=Temperature", + }, + { + name: "namespace URI format", + node: NodeSettings{ + NamespaceURI: "http://opcfoundation.org/UA/", + IdentifierType: "i", + Identifier: "2255", + }, + expected: "nsu=http://opcfoundation.org/UA/;i=2255", + }, + { + name: "namespace index with numeric identifier", + node: NodeSettings{ + Namespace: "0", + IdentifierType: "i", + Identifier: "2256", + }, + expected: "ns=0;i=2256", + }, + { + name: "namespace URI with string identifier", + node: NodeSettings{ + NamespaceURI: "http://example.com/MyNamespace", + IdentifierType: "s", + Identifier: "MyVariable", + }, + expected: "nsu=http://example.com/MyNamespace;s=MyVariable", + }, + { + name: "namespace URI with GUID identifier", + node: NodeSettings{ + NamespaceURI: "http://vendor.com/", + IdentifierType: "g", + Identifier: "12345678-1234-1234-1234-123456789012", + }, + expected: "nsu=http://vendor.com/;g=12345678-1234-1234-1234-123456789012", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tt.node.NodeID() + require.Equal(t, tt.expected, actual) + }) + } +} + +// TestEventNodeIDGeneration tests that EventNodeSettings.NodeID() generates correct node ID strings +func TestEventNodeIDGeneration(t *testing.T) { + tests := []struct { + name string + node EventNodeSettings + expected string + }{ + { + name: "event node with namespace index", + node: EventNodeSettings{ + Namespace: "1", + IdentifierType: "i", + Identifier: "2041", + }, + expected: "ns=1;i=2041", + }, + { + name: "event node with namespace URI", + node: EventNodeSettings{ + NamespaceURI: "http://opcfoundation.org/UA/", + IdentifierType: "i", + Identifier: "2253", + }, + expected: "nsu=http://opcfoundation.org/UA/;i=2253", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tt.node.NodeID() + require.Equal(t, tt.expected, actual) + }) + } +} + +// TestNodeValidationBothNamespaces tests that validation fails when both namespace and namespace_uri are set +func TestNodeValidationBothNamespaces(t *testing.T) { + existing := make(map[metricParts]struct{}) + nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{ + FieldName: "test", + Namespace: "3", + NamespaceURI: "http://opcfoundation.org/UA/", + IdentifierType: "s", + Identifier: "Temperature", + }, map[string]string{}) + require.NoError(t, err) + + err = validateNodeToAdd(existing, nmm) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot specify both 'namespace' and 'namespace_uri'") +} + +// TestNodeValidationNeitherNamespace tests that validation fails when neither namespace nor namespace_uri is set +func TestNodeValidationNeitherNamespace(t *testing.T) { + existing := make(map[metricParts]struct{}) + nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{ + FieldName: "test", + IdentifierType: "s", + Identifier: "Temperature", + }, map[string]string{}) + require.NoError(t, err) + + err = validateNodeToAdd(existing, nmm) + require.Error(t, err) + require.Contains(t, err.Error(), "must specify either 'namespace' or 'namespace_uri'") +} + +// TestEventNodeValidationBothNamespaces tests event node validation with both namespace types +func TestEventNodeValidationBothNamespaces(t *testing.T) { + node := EventNodeSettings{ + Namespace: "1", + NamespaceURI: "http://opcfoundation.org/UA/", + IdentifierType: "i", + Identifier: "2041", + } + + err := node.validateEventNodeSettings() + require.Error(t, err) + require.Contains(t, err.Error(), "cannot specify both 'namespace' and 'namespace_uri'") +} + +// TestEventNodeValidationNeitherNamespace tests event node validation with neither namespace type +func TestEventNodeValidationNeitherNamespace(t *testing.T) { + node := EventNodeSettings{ + IdentifierType: "i", + Identifier: "2041", + } + + err := node.validateEventNodeSettings() + require.Error(t, err) + require.Contains(t, err.Error(), "must specify either 'namespace' or 'namespace_uri'") +} + +// TestGroupNamespaceURIInheritance tests that nodes inherit namespace_uri from groups +func TestGroupNamespaceURIInheritance(t *testing.T) { + client := &OpcUAInputClient{ + Config: InputClientConfig{ + MetricName: "opcua", + Groups: []NodeGroupSettings{ + { + Namespace: "", + NamespaceURI: "http://opcfoundation.org/UA/", + IdentifierType: "i", + Nodes: []NodeSettings{ + { + FieldName: "node1", + Identifier: "2255", + // Should inherit namespace_uri from group + }, + { + FieldName: "node2", + Identifier: "2256", + NamespaceURI: "http://custom.org/UA/", // Override group default + }, + }, + }, + }, + }, + } + + err := client.InitNodeMetricMapping() + require.NoError(t, err) + require.Len(t, client.NodeMetricMapping, 2) + + // First node should inherit from group + require.Equal(t, "http://opcfoundation.org/UA/", client.NodeMetricMapping[0].Tag.NamespaceURI) + require.Equal(t, "nsu=http://opcfoundation.org/UA/;i=2255", client.NodeMetricMapping[0].Tag.NodeID()) + + // Second node should use its own namespace_uri + require.Equal(t, "http://custom.org/UA/", client.NodeMetricMapping[1].Tag.NamespaceURI) + require.Equal(t, "nsu=http://custom.org/UA/;i=2256", client.NodeMetricMapping[1].Tag.NodeID()) +} + +// TestEventGroupNamespaceURIInheritance tests that event nodes inherit namespace_uri from event groups +func TestEventGroupNamespaceURIInheritance(t *testing.T) { + eventGroup := EventGroupSettings{ + NamespaceURI: "http://opcfoundation.org/UA/", + IdentifierType: "i", + NodeIDSettings: []EventNodeSettings{ + { + Identifier: "2253", + // Should inherit namespace_uri from group + }, + { + Identifier: "2254", + NamespaceURI: "http://custom.org/UA/", // Override group default + }, + }, + } + + eventGroup.UpdateNodeIDSettings() + + // First node should inherit from group + require.Equal(t, "http://opcfoundation.org/UA/", eventGroup.NodeIDSettings[0].NamespaceURI) + require.Equal(t, "nsu=http://opcfoundation.org/UA/;i=2253", eventGroup.NodeIDSettings[0].NodeID()) + + // Second node should use its own namespace_uri + require.Equal(t, "http://custom.org/UA/", eventGroup.NodeIDSettings[1].NamespaceURI) + require.Equal(t, "nsu=http://custom.org/UA/;i=2254", eventGroup.NodeIDSettings[1].NodeID()) +} diff --git a/plugins/inputs/opcua/README.md b/plugins/inputs/opcua/README.md index cb5e8f63ea0c7..ce828c7beba97 100644 --- a/plugins/inputs/opcua/README.md +++ b/plugins/inputs/opcua/README.md @@ -102,10 +102,12 @@ to use them. ## Node ID configuration ## name - field name to use in the output ## namespace - OPC UA namespace of the node (integer value 0 thru 3) + ## namespace_uri - OPC UA namespace URI (alternative to namespace for stable references) ## identifier_type - OPC UA ID type (s=string, i=numeric, g=guid, b=opaque) ## identifier - OPC UA ID (tag as shown in opcua browser) ## default_tags - extra tags to be added to the output metric (optional) ## + ## Note: Specify either 'namespace' or 'namespace_uri', not both. ## Use either the inline notation or the bracketed notation, not both. ## Inline notation (default_tags not supported yet) @@ -126,6 +128,12 @@ to use them. # namespace = "" # identifier_type = "" # identifier = "" + # + # [[inputs.opcua.nodes]] + # name = "node3" + # namespace_uri = "http://opcfoundation.org/UA/" + # identifier_type = "" + # identifier = "" ## Node Group ## Sets defaults so they aren't required in every node. @@ -145,8 +153,12 @@ to use them. ## namespace, this is used. # namespace = + ## Group default namespace URI. Alternative to namespace for stable references. + ## If a node in the group doesn't set its namespace_uri, this is used. + # namespace_uri = + ## Group default identifier type. If a node in the group doesn't set its - ## namespace, this is used. + ## identifier_type, this is used. # identifier_type = ## Default tags that are applied to every node in this group. Can be @@ -223,13 +235,59 @@ using indexed keys. For example: opcua,id=ns\=3;s\=Temperature temp[0]=79.0,temp[1]=38.9,Quality="OK (0x0)",DataType="Float" 1597820490000000000 ``` +### Namespace Index vs Namespace URI + +OPC UA supports two ways to specify namespaces: + +1. **Namespace Index** (`namespace`): An integer (0-3 or higher) that references + a position in the server's namespace array. This is simpler but can change if + the server is restarted or reconfigured. + +2. **Namespace URI** (`namespace_uri`): A string URI that uniquely identifies + the namespace. This is more stable across server restarts but requires the + plugin to fetch the namespace array from the server to resolve the URI to an index. + +**When to use namespace index:** + +- For standard OPC UA namespaces (0 = OPC UA, 1 = Local Server) +- When namespace stability is not a concern +- For simpler configuration + +**When to use namespace URI:** + +- When you need consistent node references across server restarts +- For production environments where namespace indices might change +- When working with vendor-specific namespaces + +**Example using namespace URI:** + +```toml +[[inputs.opcua.nodes]] + name = "ServerStatus" + namespace_uri = "http://opcfoundation.org/UA/" + identifier_type = "i" + identifier = "2256" +``` + +This produces the same node ID internally as: + +```toml +[[inputs.opcua.nodes]] + name = "ServerStatus" + namespace = "0" + identifier_type = "i" + identifier = "2256" +``` + +Note: You must specify either `namespace` or `namespace_uri`, not both. + ## 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. +Groups can set default values for the namespace (index or URI), 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 diff --git a/plugins/inputs/opcua/read_client.go b/plugins/inputs/opcua/read_client.go index 1579669d3fdcf..f751be3ebf85e 100644 --- a/plugins/inputs/opcua/read_client.go +++ b/plugins/inputs/opcua/read_client.go @@ -88,6 +88,12 @@ func (o *readClient) connect() error { return fmt.Errorf("connect failed: %w", err) } + // Fetch namespace array for namespace URI support + if err := o.OpcUAClient.UpdateNamespaceArray(o.ctx); err != nil { + o.Log.Warnf("Failed to fetch namespace array: %v", err) + // Continue anyway - this is only needed if using namespace URIs + } + // Make sure we setup the node-ids correctly after reconnect // as the server might be restarted and IDs changed if err := o.OpcUAInputClient.InitNodeIDs(); err != nil { diff --git a/plugins/inputs/opcua/sample.conf b/plugins/inputs/opcua/sample.conf index a4186bd2d656c..958a532654057 100644 --- a/plugins/inputs/opcua/sample.conf +++ b/plugins/inputs/opcua/sample.conf @@ -70,10 +70,12 @@ ## Node ID configuration ## name - field name to use in the output ## namespace - OPC UA namespace of the node (integer value 0 thru 3) + ## namespace_uri - OPC UA namespace URI (alternative to namespace for stable references) ## identifier_type - OPC UA ID type (s=string, i=numeric, g=guid, b=opaque) ## identifier - OPC UA ID (tag as shown in opcua browser) ## default_tags - extra tags to be added to the output metric (optional) ## + ## Note: Specify either 'namespace' or 'namespace_uri', not both. ## Use either the inline notation or the bracketed notation, not both. ## Inline notation (default_tags not supported yet) @@ -94,6 +96,12 @@ # namespace = "" # identifier_type = "" # identifier = "" + # + # [[inputs.opcua.nodes]] + # name = "node3" + # namespace_uri = "http://opcfoundation.org/UA/" + # identifier_type = "" + # identifier = "" ## Node Group ## Sets defaults so they aren't required in every node. @@ -113,8 +121,12 @@ ## namespace, this is used. # namespace = + ## Group default namespace URI. Alternative to namespace for stable references. + ## If a node in the group doesn't set its namespace_uri, this is used. + # namespace_uri = + ## Group default identifier type. If a node in the group doesn't set its - ## namespace, this is used. + ## identifier_type, this is used. # identifier_type = ## Default tags that are applied to every node in this group. Can be diff --git a/plugins/inputs/opcua_listener/README.md b/plugins/inputs/opcua_listener/README.md index e27c9b2fea392..6ca8900963b06 100644 --- a/plugins/inputs/opcua_listener/README.md +++ b/plugins/inputs/opcua_listener/README.md @@ -124,11 +124,14 @@ to use them. ## Node ID configuration ## name - field name to use in the output ## namespace - OPC UA namespace of the node (integer value 0 thru 3) + ## namespace_uri - OPC UA namespace URI (alternative to namespace for stable references) ## identifier_type - OPC UA ID type (s=string, i=numeric, g=guid, b=opaque) ## identifier - OPC UA ID (tag as shown in opcua browser) ## default_tags - extra tags to be added to the output metric (optional) ## monitoring_params - additional settings for the monitored node (optional) ## + ## Note: Specify either 'namespace' or 'namespace_uri', not both. + ## ## Monitoring parameters ## sampling_interval - interval at which the server should check for data ## changes (default: 0s) @@ -191,6 +194,12 @@ to use them. # deadband_type = "Absolute" # deadband_value = 0.0 # + # [[inputs.opcua_listener.nodes]] + # name = "node3" + # namespace_uri = "http://opcfoundation.org/UA/" + # identifier_type = "" + # identifier = "" + # ## Node Group ## Sets defaults so they aren't required in every node. ## Default values can be set for: @@ -210,8 +219,12 @@ to use them. ## namespace, this is used. # namespace = # + ## Group default namespace URI. Alternative to namespace for stable references. + ## If a node in the group doesn't set its namespace_uri, this is used. + # namespace_uri = + # ## Group default identifier type. If a node in the group doesn't set its - ## namespace, this is used. + ## identifier_type, this is used. # identifier_type = # ## Default tags that are applied to every node in this group. Can be @@ -328,13 +341,59 @@ using indexed keys. For example: opcua,id=ns\=3;s\=Temperature temp[0]=79.0,temp[1]=38.9,Quality="OK (0x0)",DataType="Float" 1597820490000000000 ``` +#### Namespace Index vs Namespace URI + +OPC UA supports two ways to specify namespaces: + +1. **Namespace Index** (`namespace`): An integer (0-3 or higher) that references + a position in the server's namespace array. This is simpler but can change if + the server is restarted or reconfigured. + +2. **Namespace URI** (`namespace_uri`): A string URI that uniquely identifies + the namespace. This is more stable across server restarts but requires the + plugin to fetch the namespace array from the server to resolve the URI to an index. + +**When to use namespace index:** + +- For standard OPC UA namespaces (0 = OPC UA, 1 = Local Server) +- When namespace stability is not a concern +- For simpler configuration + +**When to use namespace URI:** + +- When you need consistent node references across server restarts +- For production environments where namespace indices might change +- When working with vendor-specific namespaces + +**Example using namespace URI:** + +```toml +[[inputs.opcua_listener.nodes]] + name = "ServerStatus" + namespace_uri = "http://opcfoundation.org/UA/" + identifier_type = "i" + identifier = "2256" +``` + +This produces the same node ID internally as: + +```toml +[[inputs.opcua_listener.nodes]] + name = "ServerStatus" + namespace = "0" + identifier_type = "i" + identifier = "2256" +``` + +Note: You must specify either `namespace` or `namespace_uri`, not both. + #### Group Configuration -Groups can set default values for the namespace, identifier type, tags -settings and sampling interval. 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. +Groups can set default values for the namespace (index or URI), identifier type, +tags settings and sampling interval. 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 diff --git a/plugins/inputs/opcua_listener/opcua_listener_test.go b/plugins/inputs/opcua_listener/opcua_listener_test.go index ea6ca67aa64bf..aa4359bdd5d3b 100644 --- a/plugins/inputs/opcua_listener/opcua_listener_test.go +++ b/plugins/inputs/opcua_listener/opcua_listener_test.go @@ -1063,7 +1063,7 @@ func TestSubscribeClientConfigEventMissingEventTypeNamespace(t *testing.T) { }) _, err := subscribeConfig.createSubscribeClient(testutil.Logger{}) - require.ErrorContains(t, err, "namespace must be set") + require.ErrorContains(t, err, "must specify either 'namespace' or 'namespace_uri'") } func TestSubscribeClientConfigEventMissingEventTypeIdentifierType(t *testing.T) { diff --git a/plugins/inputs/opcua_listener/sample.conf b/plugins/inputs/opcua_listener/sample.conf index 116ca2cb64f4a..c8372ef6c841a 100644 --- a/plugins/inputs/opcua_listener/sample.conf +++ b/plugins/inputs/opcua_listener/sample.conf @@ -81,11 +81,14 @@ ## Node ID configuration ## name - field name to use in the output ## namespace - OPC UA namespace of the node (integer value 0 thru 3) + ## namespace_uri - OPC UA namespace URI (alternative to namespace for stable references) ## identifier_type - OPC UA ID type (s=string, i=numeric, g=guid, b=opaque) ## identifier - OPC UA ID (tag as shown in opcua browser) ## default_tags - extra tags to be added to the output metric (optional) ## monitoring_params - additional settings for the monitored node (optional) ## + ## Note: Specify either 'namespace' or 'namespace_uri', not both. + ## ## Monitoring parameters ## sampling_interval - interval at which the server should check for data ## changes (default: 0s) @@ -148,6 +151,12 @@ # deadband_type = "Absolute" # deadband_value = 0.0 # + # [[inputs.opcua_listener.nodes]] + # name = "node3" + # namespace_uri = "http://opcfoundation.org/UA/" + # identifier_type = "" + # identifier = "" + # ## Node Group ## Sets defaults so they aren't required in every node. ## Default values can be set for: @@ -167,8 +176,12 @@ ## namespace, this is used. # namespace = # + ## Group default namespace URI. Alternative to namespace for stable references. + ## If a node in the group doesn't set its namespace_uri, this is used. + # namespace_uri = + # ## Group default identifier type. If a node in the group doesn't set its - ## namespace, this is used. + ## identifier_type, this is used. # identifier_type = # ## Default tags that are applied to every node in this group. Can be diff --git a/plugins/inputs/opcua_listener/subscribe_client.go b/plugins/inputs/opcua_listener/subscribe_client.go index 572f17f943c1c..38e4e1ce387e6 100644 --- a/plugins/inputs/opcua_listener/subscribe_client.go +++ b/plugins/inputs/opcua_listener/subscribe_client.go @@ -88,6 +88,7 @@ func (sc *subscribeClientConfig) createSubscribeClient(log telegraf.Logger) (*su return nil, err } + // Initialize node IDs (namespace URI resolution will happen during connect if needed) if err := client.InitNodeIDs(); err != nil { return nil, err } @@ -148,6 +149,13 @@ func (o *subscribeClient) connect() error { return err } + // Fetch namespace array for namespace URI support + // This is needed if any nodes use nsu= format instead of ns= format + if err := o.OpcUAClient.UpdateNamespaceArray(o.ctx); err != nil { + o.Log.Warnf("Failed to fetch namespace array: %v", err) + // Continue anyway - this is only needed if using namespace URIs + } + o.Log.Debugf("Creating OPC UA subscription") o.sub, err = o.Client.Subscribe(o.ctx, &opcua.SubscriptionParameters{ Interval: time.Duration(o.Config.SubscriptionInterval),