This is an automated email from the ASF dual-hosted git repository.

chrisdutz pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git

commit f7bd21f7529a882499534f8a365c1623e160a6cf
Author: Christofer Dutz <[email protected]>
AuthorDate: Tue Jul 14 09:50:58 2026 +0200

    feat(plc4j): OPC-UA browse now resolves server-side types & access rights
---
 .../apache/plc4x/java/opcua/OpcuaConnection.java   | 285 ++++++++++++++++++---
 .../plc4x/java/opcua/OpcuaPlcDriverTest.java       |  14 +
 .../java/opcua/tag/OpcuaPlcTagHandlerTest.java     |  10 +-
 3 files changed, 272 insertions(+), 37 deletions(-)

diff --git 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
index d2612e9fad..ec008bef76 100644
--- 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
+++ 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
@@ -32,6 +32,7 @@ import 
org.apache.plc4x.java.api.authentication.PlcUsernamePasswordAuthenticatio
 import org.apache.plc4x.java.api.exceptions.PlcConnectionException;
 import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
 import org.apache.plc4x.java.api.messages.*;
+import org.apache.plc4x.java.api.model.ArrayInfo;
 import org.apache.plc4x.java.api.model.PlcConsumerRegistration;
 import org.apache.plc4x.java.api.model.PlcQuery;
 import org.apache.plc4x.java.api.model.PlcSubscriptionHandle;
@@ -48,12 +49,12 @@ import org.apache.plc4x.java.opcua.context.SecureChannel;
 import org.apache.plc4x.java.opcua.protocol.OpcuaSubscriptionHandle;
 import org.apache.plc4x.java.opcua.readwrite.*;
 import org.apache.plc4x.java.opcua.tag.OpcuaPlcTagHandler;
-import org.apache.plc4x.java.opcua.tag.OpcuaQuery;
 import org.apache.plc4x.java.opcua.tag.OpcuaTag;
 import org.apache.plc4x.java.spi.drivers.ConnectionBase;
 import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException;
 import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler;
 import org.apache.plc4x.java.spi.drivers.messages.*;
+import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo;
 import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem;
 import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem;
 import 
org.apache.plc4x.java.spi.drivers.messages.DefaultPlcConsumerRegistration;
@@ -442,33 +443,38 @@ public class OpcuaConnection extends 
ConnectionBase<OpcuaConfiguration> implemen
      */
     private CompletableFuture<List<PlcBrowseItem>> browseNode(NodeId nodeId, 
Set<String> visited, String queryName,
                                                               PlcQuery query, 
PlcBrowseRequestInterceptor interceptor) {
-        return browseReferences(nodeId, null, new 
ArrayList<>()).thenCompose(references -> {
-            List<CompletableFuture<PlcBrowseItem>> itemFutures = new 
ArrayList<>();
-            for (ReferenceDescription reference : references) {
-                String childAddress = addressOf(reference.getNodeId());
-                if (childAddress == null) {
-                    // Node identifier form we can't turn back into an address 
(e.g. opaque) — skip.
-                    continue;
-                }
-                CompletableFuture<Map<String, PlcBrowseItem>> childrenFuture;
-                if (visited.add(childAddress)) {
-                    childrenFuture = 
browseNode(generateNodeId(OpcuaTag.of(childAddress)),
-                        visited, queryName, query, interceptor)
-                        .thenApply(childItems -> childItems.stream()
-                            .collect(Collectors.toMap(PlcBrowseItem::getName, 
item -> item,
-                                (a, b) -> a, LinkedHashMap::new)));
-                } else {
-                    // Already visited (cycle or shared node) — list it but 
don't expand again.
-                    childrenFuture = 
CompletableFuture.completedFuture(Collections.emptyMap());
+        return browseReferences(nodeId, null, new 
ArrayList<>()).thenCompose(references ->
+            // Resolve the server-side type/access attributes of all variable 
children in one
+            // batched Read before turning the references into browse items.
+            resolveVariableAttributes(references).thenCompose(attributes -> {
+                List<CompletableFuture<PlcBrowseItem>> itemFutures = new 
ArrayList<>();
+                for (ReferenceDescription reference : references) {
+                    String childAddress = addressOf(reference.getNodeId());
+                    if (childAddress == null) {
+                        // Node identifier form we can't turn back into an 
address (e.g. opaque) — skip.
+                        continue;
+                    }
+                    CompletableFuture<Map<String, PlcBrowseItem>> 
childrenFuture;
+                    if (visited.add(childAddress)) {
+                        childrenFuture = 
browseNode(generateNodeId(OpcuaTag.of(childAddress)),
+                            visited, queryName, query, interceptor)
+                            .thenApply(childItems -> childItems.stream()
+                                
.collect(Collectors.toMap(PlcBrowseItem::getName, item -> item,
+                                    (a, b) -> a, LinkedHashMap::new)));
+                    } else {
+                        // Already visited (cycle or shared node) — list it 
but don't expand again.
+                        childrenFuture = 
CompletableFuture.completedFuture(Collections.emptyMap());
+                    }
+                    NodeAttributes childAttributes = 
attributes.get(childAddress);
+                    itemFutures.add(childrenFuture.thenApply(children ->
+                        buildBrowseItem(reference, childAddress, 
childAttributes, children)));
                 }
-                itemFutures.add(childrenFuture.thenApply(children -> 
buildBrowseItem(reference, childAddress, children)));
-            }
-            return CompletableFuture.allOf(itemFutures.toArray(new 
CompletableFuture[0]))
-                .thenApply(v -> itemFutures.stream()
-                    .map(CompletableFuture::join)
-                    .filter(item -> interceptor.intercept(queryName, query, 
item))
-                    .collect(Collectors.toList()));
-        });
+                return CompletableFuture.allOf(itemFutures.toArray(new 
CompletableFuture[0]))
+                    .thenApply(v -> itemFutures.stream()
+                        .map(CompletableFuture::join)
+                        .filter(item -> interceptor.intercept(queryName, 
query, item))
+                        .collect(Collectors.toList()));
+            }));
     }
 
     /**
@@ -513,8 +519,8 @@ public class OpcuaConnection extends 
ConnectionBase<OpcuaConfiguration> implemen
         });
     }
 
-    private PlcBrowseItem buildBrowseItem(ReferenceDescription reference, 
String address, Map<String, PlcBrowseItem> children) {
-        OpcuaTag tag = OpcuaTag.of(address);
+    private PlcBrowseItem buildBrowseItem(ReferenceDescription reference, 
String address,
+                                          NodeAttributes attributes, 
Map<String, PlcBrowseItem> children) {
         NodeClass nodeClass = reference.getNodeClass();
         boolean isVariable = nodeClass == NodeClass.nodeClassVariable;
         String name = localizedTextValue(reference.getDisplayName());
@@ -522,6 +528,28 @@ public class OpcuaConnection extends 
ConnectionBase<OpcuaConfiguration> implemen
             name = qualifiedNameValue(reference.getBrowseName());
         }
 
+        // Fold the server-resolved data type into the tag address (e.g. 
"ns=2;i=3;DINT") so the
+        // resulting tag is correctly typed for reads/writes without a manual 
type suffix.
+        OpcuaDataType dataType = attributes != null ? attributes.dataType : 
null;
+        boolean hasDataType = dataType != null && dataType != 
OpcuaDataType.NULL;
+        OpcuaTag tag = hasDataType ? OpcuaTag.of(address + ";" + 
dataType.name()) : OpcuaTag.of(address);
+
+        boolean readable;
+        boolean writable;
+        List<ArrayInfo> arrayInfo;
+        if (attributes != null) {
+            // Real access rights and array shape read from the server.
+            readable = attributes.readable;
+            writable = attributes.writable;
+            arrayInfo = attributes.arrayInfo;
+        } else {
+            // Non-variable nodes (or variables whose attributes couldn't be 
read): fall back to the
+            // NodeClass heuristic — only variables are considered 
readable/writable.
+            readable = isVariable;
+            writable = isVariable;
+            arrayInfo = Collections.emptyList();
+        }
+
         Map<String, PlcValue> options = new HashMap<>();
         if (nodeClass != null) {
             options.put("node-class", new PlcSTRING(nodeClass.name()));
@@ -530,11 +558,202 @@ public class OpcuaConnection extends 
ConnectionBase<OpcuaConfiguration> implemen
         if (browseName != null) {
             options.put("browse-name", new PlcSTRING(browseName));
         }
+        if (hasDataType) {
+            options.put("data-type", new PlcSTRING(dataType.name()));
+        }
+
+        return new DefaultPlcBrowseItem(tag, name, readable, writable,
+            Collections.<PlcSubscriptionType>emptySet(), false, arrayInfo, 
children, options);
+    }
+
+    // The node attributes read for every variable child during a browse so 
items carry a real
+    // data type, array shape and access rights rather than the 
NodeClass-based guess of Phase 1.
+    private static final AttributeId[] BROWSE_ATTRIBUTES = {
+        AttributeId.DataType, AttributeId.ValueRank, 
AttributeId.ArrayDimensions, AttributeId.AccessLevel};
+
+    /** The server-resolved metadata of a single variable node. */
+    private static final class NodeAttributes {
+        private final OpcuaDataType dataType;      // resolved built-in type, 
or null if unresolved
+        private final List<ArrayInfo> arrayInfo;   // empty for scalars
+        private final boolean readable;
+        private final boolean writable;
+
+        private NodeAttributes(OpcuaDataType dataType, List<ArrayInfo> 
arrayInfo, boolean readable, boolean writable) {
+            this.dataType = dataType;
+            this.arrayInfo = arrayInfo;
+            this.readable = readable;
+            this.writable = writable;
+        }
+    }
+
+    /**
+     * Reads DataType/ValueRank/ArrayDimensions/AccessLevel for every variable 
node among the given
+     * references in a single batched {@link ReadRequest} and returns a map 
keyed by node address.
+     * Object/method/other node classes are skipped (they have no such 
attributes). If the read
+     * fails the browse still succeeds — items just fall back to the NodeClass 
heuristic.
+     */
+    private CompletableFuture<Map<String, NodeAttributes>> 
resolveVariableAttributes(List<ReferenceDescription> references) {
+        List<String> variableAddresses = new ArrayList<>();
+        Set<String> seen = new HashSet<>();
+        for (ReferenceDescription reference : references) {
+            if (reference.getNodeClass() != NodeClass.nodeClassVariable) {
+                continue;
+            }
+            String address = addressOf(reference.getNodeId());
+            if (address != null && seen.add(address)) {
+                variableAddresses.add(address);
+            }
+        }
+        if (variableAddresses.isEmpty()) {
+            return CompletableFuture.completedFuture(Collections.emptyMap());
+        }
+
+        List<ReadValueId> readValueIds = new 
ArrayList<>(variableAddresses.size() * BROWSE_ATTRIBUTES.length);
+        for (String address : variableAddresses) {
+            NodeId childNodeId = generateNodeId(OpcuaTag.of(address));
+            for (AttributeId attributeId : BROWSE_ATTRIBUTES) {
+                readValueIds.add(new ReadValueId(childNodeId, 
attributeId.getValue(),
+                    NULL_STRING, new QualifiedName(0, NULL_STRING)));
+            }
+        }
+        ReadRequest readRequest = new 
ReadRequest(conversation.createRequestHeader(), 0.0d,
+            TimestampsToReturn.timestampsToReturnNeither, readValueIds);
+
+        return conversation.submit(readRequest, 
ReadResponse.class).thenApply(response -> {
+            Map<String, NodeAttributes> resolved = new HashMap<>();
+            List<DataValue> results = response.getResults();
+            int index = 0;
+            for (String address : variableAddresses) {
+                DataValue dataTypeValue = results.get(index++);
+                DataValue valueRankValue = results.get(index++);
+                DataValue arrayDimensionsValue = results.get(index++);
+                DataValue accessLevelValue = results.get(index++);
+
+                Short accessLevel = byteValue(accessLevelValue);
+                // AccessLevel bit0 = CurrentRead, bit1 = CurrentWrite. If the 
attribute couldn't be
+                // read, assume read/write (matching the earlier 
NodeClass-based default).
+                boolean readable = accessLevel == null || (accessLevel & 0x01) 
!= 0;
+                boolean writable = accessLevel == null || (accessLevel & 0x02) 
!= 0;
+
+                resolved.put(address, new NodeAttributes(
+                    resolveDataType(dataTypeValue),
+                    arrayInfoOf(valueRankValue, arrayDimensionsValue),
+                    readable, writable));
+            }
+            return resolved;
+        }).exceptionally(error -> {
+            LOGGER.warn("Failed to resolve variable node attributes during 
browse; "
+                + "browse items fall back to the NodeClass heuristic", error);
+            return Collections.emptyMap();
+        });
+    }
 
-        // Phase 1: only variable nodes are readable/writable. The actual 
access rights (and the
-        // data type) are refined once server-side type resolution is wired in.
-        return new DefaultPlcBrowseItem(tag, name, isVariable, isVariable,
-            Collections.<PlcSubscriptionType>emptySet(), false, 
Collections.emptyList(), children, options);
+    /**
+     * Resolves the DataType attribute (a NodeId) to an {@link OpcuaDataType}. 
Built-in DataType
+     * NodeIds live in namespace 0 and share their numeric identifier with the 
OPC UA Variant
+     * built-in type (Boolean=1, Int32=6, ...), so we can map straight 
through. Custom/enum/struct
+     * data types (non-namespace-0 or non-numeric) are left unresolved for a 
later phase.
+     */
+    private static OpcuaDataType resolveDataType(DataValue dataValue) {
+        if (dataValue == null || !dataValue.getValueSpecified() || 
!(dataValue.getValue() instanceof VariantNodeId)) {
+            return null;
+        }
+        List<NodeId> nodeIds = ((VariantNodeId) 
dataValue.getValue()).getValue();
+        if (nodeIds == null || nodeIds.isEmpty()) {
+            return null;
+        }
+        NodeId dataTypeNodeId = nodeIds.get(0);
+        if (namespaceOf(dataTypeNodeId) != 0) {
+            return null;
+        }
+        Long numericId = numericIdentifierOf(dataTypeNodeId);
+        if (numericId == null || numericId <= 0 || numericId > 
Short.MAX_VALUE) {
+            return null;
+        }
+        return 
OpcuaDataType.firstEnumForFieldVariantType(numericId.shortValue());
+    }
+
+    /**
+     * Builds the array shape from the ValueRank and ArrayDimensions 
attributes. ValueRank &lt; 0
+     * (-1 scalar, -2 any, -3 scalar-or-one-dim) yields no array info; 
ValueRank &gt;= 0 marks an
+     * array, its per-dimension sizes taken from ArrayDimensions when the 
server supplies them.
+     */
+    private static List<ArrayInfo> arrayInfoOf(DataValue valueRankValue, 
DataValue arrayDimensionsValue) {
+        Integer valueRank = int32Value(valueRankValue);
+        if (valueRank == null || valueRank < 0) {
+            return Collections.emptyList();
+        }
+        List<Long> dimensions = uint32Values(arrayDimensionsValue);
+        List<ArrayInfo> arrayInfo = new ArrayList<>();
+        if (dimensions != null && !dimensions.isEmpty()) {
+            for (Long dimension : dimensions) {
+                // A dimension of 0 means "unknown size" per the spec — 
represent it as an empty range.
+                int upper = (dimension == null || dimension <= 0) ? -1 : (int) 
(dimension - 1);
+                arrayInfo.add(new DefaultArrayInfo(0, upper));
+            }
+        } else {
+            // We know the rank but not the sizes (ValueRank 0 => one-or-more 
dimensions).
+            int rank = Math.max(valueRank, 1);
+            for (int i = 0; i < rank; i++) {
+                arrayInfo.add(new DefaultArrayInfo(0, -1));
+            }
+        }
+        return arrayInfo;
+    }
+
+    /** Extracts the first Byte (unsigned, as Short) from a Variant, or null 
if not a byte scalar. */
+    private static Short byteValue(DataValue dataValue) {
+        if (dataValue == null || !dataValue.getValueSpecified() || 
!(dataValue.getValue() instanceof VariantByte)) {
+            return null;
+        }
+        List<Short> values = ((VariantByte) dataValue.getValue()).getValue();
+        return (values == null || values.isEmpty()) ? null : values.get(0);
+    }
+
+    /** Extracts the first Int32 from a Variant, or null if not an int32 
scalar. */
+    private static Integer int32Value(DataValue dataValue) {
+        if (dataValue == null || !dataValue.getValueSpecified() || 
!(dataValue.getValue() instanceof VariantInt32)) {
+            return null;
+        }
+        List<Integer> values = ((VariantInt32) 
dataValue.getValue()).getValue();
+        return (values == null || values.isEmpty()) ? null : values.get(0);
+    }
+
+    /** Extracts the UInt32 list from a Variant, or null if not a uint32 
value. */
+    private static List<Long> uint32Values(DataValue dataValue) {
+        if (dataValue == null || !dataValue.getValueSpecified() || 
!(dataValue.getValue() instanceof VariantUInt32)) {
+            return null;
+        }
+        return ((VariantUInt32) dataValue.getValue()).getValue();
+    }
+
+    /** Namespace index of a NodeId (0 for the implicit-namespace two-byte 
form; -1 if unknown). */
+    private static int namespaceOf(NodeId nodeId) {
+        NodeIdTypeDefinition definition = nodeId.getNodeId();
+        if (definition instanceof NodeIdTwoByte) {
+            return 0;
+        } else if (definition instanceof NodeIdFourByte fourByte) {
+            return fourByte.getNamespaceIndex();
+        } else if (definition instanceof NodeIdNumeric numeric) {
+            return numeric.getNamespaceIndex();
+        } else if (definition instanceof NodeIdString string) {
+            return string.getNamespaceIndex();
+        }
+        return -1;
+    }
+
+    /** Numeric identifier of a NodeId, or null when the identifier isn't 
numeric (string/guid/opaque). */
+    private static Long numericIdentifierOf(NodeId nodeId) {
+        NodeIdTypeDefinition definition = nodeId.getNodeId();
+        if (definition instanceof NodeIdTwoByte || definition instanceof 
NodeIdFourByte
+                || definition instanceof NodeIdNumeric) {
+            try {
+                return Long.parseLong(definition.getIdentifier());
+            } catch (NumberFormatException e) {
+                return null;
+            }
+        }
+        return null;
     }
 
     /** Formats the target node of a reference back into an OpcuaTag address 
(or null if unsupported). */
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
index 3ce07f31fa..fc6daa2d51 100644
--- 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
+++ 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
@@ -229,6 +229,11 @@ public class OpcuaPlcDriverTest {
             java.util.List<org.apache.plc4x.java.api.messages.PlcBrowseItem> 
all = new ArrayList<>();
             collectBrowseItems(top, all);
             assertThat(all.size()).isGreaterThan(50);
+
+            // Phase 2: server-side type resolution applied across the whole 
address space — many
+            // variables carry a resolved data type, and array variables 
expose their dimensions.
+            assertThat(all).anyMatch(i -> 
i.getOptions().containsKey("data-type"));
+            assertThat(all).anyMatch(i -> !i.getArrayInformation().isEmpty());
         }
     }
 
@@ -260,6 +265,15 @@ public class OpcuaPlcDriverTest {
             assertThat(boolVar.isWritable()).isTrue();
             
assertThat(boolVar.getTag().getAddressString()).contains("ns=2;s=HelloWorld/ScalarTypes/Boolean");
 
+            // Phase 2: the variable's data type is resolved from the server 
(DataType attribute),
+            // so the tag is correctly typed (address gains a ";BOOL" suffix) 
without a manual hint,
+            // and the resolved type is surfaced as a browse option. A scalar 
has no array info.
+            assertThat(boolVar.getTag().getAddressString()).endsWith(";BOOL");
+            assertThat(((OpcuaTag) boolVar.getTag()).getDataType())
+                
.isEqualTo(org.apache.plc4x.java.opcua.readwrite.OpcuaDataType.BOOL);
+            assertThat(boolVar.getOptions()).containsKey("data-type");
+            assertThat(boolVar.getArrayInformation()).isEmpty();
+
             // Full-subtree recursion reached deeply-nested property nodes 
(AnalogValue -> EURange).
             java.util.List<org.apache.plc4x.java.api.messages.PlcBrowseItem> 
all = new ArrayList<>();
             collectBrowseItems(top, all);
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/tag/OpcuaPlcTagHandlerTest.java
 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/tag/OpcuaPlcTagHandlerTest.java
index db2bcbd379..a396d457df 100644
--- 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/tag/OpcuaPlcTagHandlerTest.java
+++ 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/tag/OpcuaPlcTagHandlerTest.java
@@ -43,10 +43,12 @@ class OpcuaPlcTagHandlerTest {
     }
 
     @Test
-    void supportsBrowsing() {
-        PlcQuery query = handler.parseQuery("anything");
-        assertThat(query).isNotNull();
-        assertThat(query.getQueryString()).isEqualTo("anything");
+    void parsesBrowseQuery() {
+        // Browsing is supported: parseQuery turns the browse expression into 
an OpcuaQuery
+        // carrying the start-address (or wildcard) string verbatim.
+        PlcQuery query = handler.parseQuery("ns=2;s=HelloWorld");
+        assertThat(query).isInstanceOf(OpcuaQuery.class);
+        assertThat(query.getQueryString()).isEqualTo("ns=2;s=HelloWorld");
     }
 
 }

Reply via email to