Skip to content

Commit 0095a48

Browse files
authored
fix(inputs.opcua_listener): Parse namespace prefixes and nested browse paths in event fields (#18586)
1 parent 5d40e2a commit 0095a48

File tree

4 files changed

+165
-3
lines changed

4 files changed

+165
-3
lines changed

plugins/common/opcua/input/input_client.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,15 +752,50 @@ func (node *EventNodeMetricMapping) createSelectClauses() ([]*ua.SimpleAttribute
752752
return nil, err
753753
}
754754
for i, name := range node.Fields {
755+
browsePath, err := parseBrowsePath(name)
756+
if err != nil {
757+
return nil, fmt.Errorf("parsing field %q failed: %w", name, err)
758+
}
755759
selects[i] = &ua.SimpleAttributeOperand{
756760
TypeDefinitionID: typeDefinition,
757-
BrowsePath: []*ua.QualifiedName{{NamespaceIndex: 0, Name: name}},
761+
BrowsePath: browsePath,
758762
AttributeID: ua.AttributeIDValue,
759763
}
760764
}
761765
return selects, nil
762766
}
763767

768+
// parseBrowsePath parses a field name into a browse path of qualified names.
769+
// It supports namespace-qualified segments (e.g. "2:TEXT01") and multi-segment
770+
// paths separated by "/" (e.g. "AckedState/Id" or "2:AckedState/0:Id").
771+
func parseBrowsePath(field string) ([]*ua.QualifiedName, error) {
772+
segments := strings.Split(field, "/")
773+
path := make([]*ua.QualifiedName, 0, len(segments))
774+
for _, seg := range segments {
775+
if seg == "" {
776+
return nil, fmt.Errorf("empty segment in browse path %q", field)
777+
}
778+
ns, name := parseQualifiedName(seg)
779+
path = append(path, &ua.QualifiedName{NamespaceIndex: ns, Name: name})
780+
}
781+
return path, nil
782+
}
783+
784+
// parseQualifiedName parses a single segment like "2:TEXT01" into a namespace
785+
// index and name. If no namespace prefix is present, namespace 0 is used.
786+
func parseQualifiedName(segment string) (uint16, string) {
787+
prefix, name, found := strings.Cut(segment, ":")
788+
if !found {
789+
return 0, segment
790+
}
791+
ns, err := strconv.ParseUint(prefix, 10, 16)
792+
if err != nil {
793+
// Not a valid namespace prefix, treat the whole segment as the name
794+
return 0, segment
795+
}
796+
return uint16(ns), name
797+
}
798+
764799
func (node *EventNodeMetricMapping) createWhereClauses() (*ua.ContentFilter, error) {
765800
if len(node.SourceNames) == 0 {
766801
return &ua.ContentFilter{

plugins/common/opcua/input/input_client_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1427,3 +1427,124 @@ func TestEventGroupWithNodeIDString(t *testing.T) {
14271427
require.Equal(t, "ns=2;s=EventSource1", eventGroup.NodeIDSettings[0].NodeID())
14281428
require.Equal(t, "nsu=http://example.org/;i=200", eventGroup.NodeIDSettings[1].NodeID())
14291429
}
1430+
1431+
func TestParseBrowsePath(t *testing.T) {
1432+
tests := []struct {
1433+
name string
1434+
field string
1435+
expected []*ua.QualifiedName
1436+
}{
1437+
{
1438+
name: "simple field",
1439+
field: "Severity",
1440+
expected: []*ua.QualifiedName{
1441+
{NamespaceIndex: 0, Name: "Severity"},
1442+
},
1443+
},
1444+
{
1445+
name: "namespace qualified field",
1446+
field: "2:TEXT01",
1447+
expected: []*ua.QualifiedName{
1448+
{NamespaceIndex: 2, Name: "TEXT01"},
1449+
},
1450+
},
1451+
{
1452+
name: "nested browse path",
1453+
field: "AckedState/Id",
1454+
expected: []*ua.QualifiedName{
1455+
{NamespaceIndex: 0, Name: "AckedState"},
1456+
{NamespaceIndex: 0, Name: "Id"},
1457+
},
1458+
},
1459+
{
1460+
name: "namespace qualified nested path",
1461+
field: "2:AckedState/0:Id",
1462+
expected: []*ua.QualifiedName{
1463+
{NamespaceIndex: 2, Name: "AckedState"},
1464+
{NamespaceIndex: 0, Name: "Id"},
1465+
},
1466+
},
1467+
{
1468+
name: "non-numeric prefix treated as name",
1469+
field: "abc:def",
1470+
expected: []*ua.QualifiedName{
1471+
{NamespaceIndex: 0, Name: "abc:def"},
1472+
},
1473+
},
1474+
{
1475+
name: "namespace zero explicit",
1476+
field: "0:Message",
1477+
expected: []*ua.QualifiedName{
1478+
{NamespaceIndex: 0, Name: "Message"},
1479+
},
1480+
},
1481+
}
1482+
1483+
for _, tt := range tests {
1484+
t.Run(tt.name, func(t *testing.T) {
1485+
result, err := parseBrowsePath(tt.field)
1486+
require.NoError(t, err)
1487+
require.Equal(t, tt.expected, result)
1488+
})
1489+
}
1490+
}
1491+
1492+
func TestParseBrowsePathError(t *testing.T) {
1493+
tests := []struct {
1494+
name string
1495+
field string
1496+
errText string
1497+
}{
1498+
{
1499+
name: "empty string",
1500+
field: "",
1501+
errText: "empty segment",
1502+
},
1503+
{
1504+
name: "leading slash",
1505+
field: "/Severity",
1506+
errText: "empty segment",
1507+
},
1508+
{
1509+
name: "trailing slash",
1510+
field: "Severity/",
1511+
errText: "empty segment",
1512+
},
1513+
{
1514+
name: "double slash",
1515+
field: "AckedState//Id",
1516+
errText: "empty segment",
1517+
},
1518+
}
1519+
1520+
for _, tt := range tests {
1521+
t.Run(tt.name, func(t *testing.T) {
1522+
_, err := parseBrowsePath(tt.field)
1523+
require.ErrorContains(t, err, tt.errText)
1524+
})
1525+
}
1526+
}
1527+
1528+
func TestCreateSelectClausesWithNamespacedFields(t *testing.T) {
1529+
node := &EventNodeMetricMapping{
1530+
EventTypeNode: ua.NewNumericNodeID(0, 2041),
1531+
Fields: []string{"Severity", "2:TEXT01", "AckedState/Id"},
1532+
}
1533+
1534+
selects, err := node.createSelectClauses()
1535+
require.NoError(t, err)
1536+
require.Len(t, selects, 3)
1537+
1538+
require.Equal(t, []*ua.QualifiedName{
1539+
{NamespaceIndex: 0, Name: "Severity"},
1540+
}, selects[0].BrowsePath)
1541+
1542+
require.Equal(t, []*ua.QualifiedName{
1543+
{NamespaceIndex: 2, Name: "TEXT01"},
1544+
}, selects[1].BrowsePath)
1545+
1546+
require.Equal(t, []*ua.QualifiedName{
1547+
{NamespaceIndex: 0, Name: "AckedState"},
1548+
{NamespaceIndex: 0, Name: "Id"},
1549+
}, selects[2].BrowsePath)
1550+
}

plugins/inputs/opcua_listener/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,10 @@ to use them.
295295
# # identifier_type = ""
296296
# ## Specifies OPCUA Event sources to filter on
297297
# # source_names = ["SourceName1", "SourceName2"]
298-
# ## Fields to capture from event notifications
298+
# ## Fields to capture from event notifications.
299+
# ## Fields support namespace-qualified names using "ns:name" format
300+
# ## (e.g. "2:TEXT01") and nested browse paths using "/" as separator
301+
# ## (e.g. "AckedState/Id" or "2:AckedState/0:Id").
299302
# fields = ["Severity", "Message", "Time"]
300303
#
301304
# ## Type or level of events to capture from the monitored nodes.

plugins/inputs/opcua_listener/sample.conf

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,10 @@
253253
# # identifier_type = ""
254254
# ## Specifies OPCUA Event sources to filter on
255255
# # source_names = ["SourceName1", "SourceName2"]
256-
# ## Fields to capture from event notifications
256+
# ## Fields to capture from event notifications.
257+
# ## Fields support namespace-qualified names using "ns:name" format
258+
# ## (e.g. "2:TEXT01") and nested browse paths using "/" as separator
259+
# ## (e.g. "AckedState/Id" or "2:AckedState/0:Id").
257260
# fields = ["Severity", "Message", "Time"]
258261
#
259262
# ## Type or level of events to capture from the monitored nodes.

0 commit comments

Comments
 (0)