You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by cd...@apache.org on 2026/07/14 10:01:48 UTC
(plc4x) 01/02: feat(plc4j): OPC-UA browse now resolves server-side types & access rights
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 <ch...@c-ware.de>
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 < 0
+ * (-1 scalar, -2 any, -3 scalar-or-one-dim) yields no array info; ValueRank >= 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");
}
}