Skip to content

Commit 2631a7c

Browse files
committed
feat: Added browse support for PLC4J OPC-UA
1 parent 8cbd274 commit 2631a7c

6 files changed

Lines changed: 405 additions & 6 deletions

File tree

plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java

Lines changed: 204 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,17 @@
2727
import java.util.concurrent.ConcurrentHashMap;
2828
import java.util.concurrent.CopyOnWriteArrayList;
2929
import java.util.concurrent.TimeUnit;
30-
import java.util.concurrent.TimeoutException;
3130
import org.apache.plc4x.java.api.authentication.PlcAuthentication;
3231
import org.apache.plc4x.java.api.authentication.PlcUsernamePasswordAuthentication;
3332
import org.apache.plc4x.java.api.exceptions.PlcConnectionException;
3433
import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
3534
import org.apache.plc4x.java.api.messages.*;
36-
import org.apache.plc4x.java.api.metadata.Metadata;
37-
import org.apache.plc4x.java.api.metadata.time.TimeSource;
3835
import org.apache.plc4x.java.api.model.PlcConsumerRegistration;
36+
import org.apache.plc4x.java.api.model.PlcQuery;
3937
import org.apache.plc4x.java.api.model.PlcSubscriptionHandle;
4038
import org.apache.plc4x.java.api.model.PlcTag;
4139
import org.apache.plc4x.java.api.types.PlcResponseCode;
40+
import org.apache.plc4x.java.api.types.PlcSubscriptionType;
4241
import org.apache.plc4x.java.api.types.PlcValueType;
4342
import org.apache.plc4x.java.api.value.PlcValue;
4443
import org.apache.plc4x.java.opcua.config.OpcuaConfiguration;
@@ -49,9 +48,8 @@
4948
import org.apache.plc4x.java.opcua.protocol.OpcuaSubscriptionHandle;
5049
import org.apache.plc4x.java.opcua.readwrite.*;
5150
import org.apache.plc4x.java.opcua.tag.OpcuaPlcTagHandler;
52-
import org.apache.plc4x.java.opcua.tag.OpcuaQualityStatus;
51+
import org.apache.plc4x.java.opcua.tag.OpcuaQuery;
5352
import org.apache.plc4x.java.opcua.tag.OpcuaTag;
54-
import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException;
5553
import org.apache.plc4x.java.spi.drivers.ConnectionBase;
5654
import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException;
5755
import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler;
@@ -74,6 +72,7 @@
7472
import java.util.concurrent.CompletableFuture;
7573
import java.util.function.Consumer;
7674
import java.util.function.Predicate;
75+
import java.util.stream.Collectors;
7776

7877
public class OpcuaConnection extends ConnectionBase<OpcuaConfiguration> implements OpcuaWire {
7978

@@ -374,6 +373,206 @@ public Map<String, PlcResponseItem<PlcValue>> readResponse(Map<String, PlcTag> t
374373
return response;
375374
}
376375

376+
// The standard "Objects" folder — the usual entry point into a server's address space.
377+
private static final String OBJECTS_FOLDER_ADDRESS = "ns=0;i=85";
378+
// HierarchicalReferences (i=33): browsing only these (plus subtypes) yields the clean
379+
// containment tree (Organizes / HasComponent / HasProperty, ...) instead of every
380+
// reference type (which would drag in type definitions and other non-containment noise).
381+
private static final NodeId HIERARCHICAL_REFERENCES = new NodeId(new NodeIdNumeric(0, 33L));
382+
// resultMask 0x3F -> return all reference fields (referenceType, isForward, nodeClass,
383+
// browseName, displayName, typeDefinition).
384+
private static final long BROWSE_RESULT_MASK_ALL = 0x3FL;
385+
386+
@Override
387+
protected CompletableFuture<PlcBrowseResponse> onBrowse(PlcBrowseRequest browseRequest) {
388+
return onBrowseWithInterceptor(browseRequest, (queryName, query, item) -> true);
389+
}
390+
391+
@Override
392+
protected CompletableFuture<PlcBrowseResponse> onBrowseWithInterceptor(PlcBrowseRequest browseRequest,
393+
PlcBrowseRequestInterceptor interceptor) {
394+
Map<String, PlcResponseCode> responseCodes = new ConcurrentHashMap<>();
395+
Map<String, List<PlcBrowseItem>> values = new ConcurrentHashMap<>();
396+
List<CompletableFuture<?>> queryFutures = new ArrayList<>();
397+
398+
for (String queryName : browseRequest.getQueryNames()) {
399+
PlcQuery query = browseRequest.getQuery(queryName);
400+
String startAddress = query.getQueryString();
401+
// An empty query or the "**" wildcard means "browse everything": start at the
402+
// standard Objects folder and recurse the whole sub-tree. Otherwise the query is
403+
// the address of the node to start browsing from.
404+
if (startAddress == null || startAddress.isBlank() || "**".equals(startAddress.trim())) {
405+
startAddress = OBJECTS_FOLDER_ADDRESS;
406+
}
407+
NodeId startNodeId;
408+
try {
409+
startNodeId = generateNodeId(OpcuaTag.of(startAddress));
410+
} catch (Exception e) {
411+
LOGGER.warn("Invalid browse start node '{}' for query '{}'", startAddress, queryName, e);
412+
responseCodes.put(queryName, PlcResponseCode.INVALID_ADDRESS);
413+
values.put(queryName, Collections.emptyList());
414+
continue;
415+
}
416+
// A shared, global set of already-visited nodes provides cycle detection (a node
417+
// is never expanded twice) and keeps a well-formed tree from being duplicated.
418+
Set<String> visited = ConcurrentHashMap.newKeySet();
419+
queryFutures.add(
420+
browseNode(startNodeId, visited, queryName, query, interceptor)
421+
.handle((items, error) -> {
422+
if (error != null) {
423+
LOGGER.warn("Browsing failed for query '{}'", queryName, error);
424+
responseCodes.put(queryName, PlcResponseCode.INTERNAL_ERROR);
425+
values.put(queryName, Collections.emptyList());
426+
} else {
427+
responseCodes.put(queryName, PlcResponseCode.OK);
428+
values.put(queryName, items);
429+
}
430+
return null;
431+
}));
432+
}
433+
434+
return CompletableFuture.allOf(queryFutures.toArray(new CompletableFuture[0]))
435+
.thenApply(v -> new DefaultPlcBrowseResponse(browseRequest, responseCodes, values));
436+
}
437+
438+
/**
439+
* Browses the forward hierarchical references of {@code nodeId} and returns each referenced
440+
* node as a {@link PlcBrowseItem}, recursing into every not-yet-visited child so the full
441+
* sub-tree is returned. {@code visited} guards against reference cycles.
442+
*/
443+
private CompletableFuture<List<PlcBrowseItem>> browseNode(NodeId nodeId, Set<String> visited, String queryName,
444+
PlcQuery query, PlcBrowseRequestInterceptor interceptor) {
445+
return browseReferences(nodeId, null, new ArrayList<>()).thenCompose(references -> {
446+
List<CompletableFuture<PlcBrowseItem>> itemFutures = new ArrayList<>();
447+
for (ReferenceDescription reference : references) {
448+
String childAddress = addressOf(reference.getNodeId());
449+
if (childAddress == null) {
450+
// Node identifier form we can't turn back into an address (e.g. opaque) — skip.
451+
continue;
452+
}
453+
CompletableFuture<Map<String, PlcBrowseItem>> childrenFuture;
454+
if (visited.add(childAddress)) {
455+
childrenFuture = browseNode(generateNodeId(OpcuaTag.of(childAddress)),
456+
visited, queryName, query, interceptor)
457+
.thenApply(childItems -> childItems.stream()
458+
.collect(Collectors.toMap(PlcBrowseItem::getName, item -> item,
459+
(a, b) -> a, LinkedHashMap::new)));
460+
} else {
461+
// Already visited (cycle or shared node) — list it but don't expand again.
462+
childrenFuture = CompletableFuture.completedFuture(Collections.emptyMap());
463+
}
464+
itemFutures.add(childrenFuture.thenApply(children -> buildBrowseItem(reference, childAddress, children)));
465+
}
466+
return CompletableFuture.allOf(itemFutures.toArray(new CompletableFuture[0]))
467+
.thenApply(v -> itemFutures.stream()
468+
.map(CompletableFuture::join)
469+
.filter(item -> interceptor.intercept(queryName, query, item))
470+
.collect(Collectors.toList()));
471+
});
472+
}
473+
474+
/**
475+
* Issues a single Browse (or BrowseNext when {@code continuationPoint} is set) and follows
476+
* continuation points until the server has returned all references of the node.
477+
*/
478+
private CompletableFuture<List<ReferenceDescription>> browseReferences(NodeId nodeId, PascalByteString continuationPoint,
479+
List<ReferenceDescription> accumulator) {
480+
CompletableFuture<BrowseResult> resultFuture;
481+
if (continuationPoint == null) {
482+
BrowseDescription description = new BrowseDescription(
483+
nodeId,
484+
BrowseDirection.browseDirectionForward,
485+
HIERARCHICAL_REFERENCES,
486+
true, // include reference subtypes
487+
0L, // nodeClassMask 0 -> all node classes
488+
BROWSE_RESULT_MASK_ALL);
489+
BrowseRequest request = new BrowseRequest(
490+
conversation.createRequestHeader(),
491+
new ViewDescription(new NodeId(new NodeIdTwoByte((short) 0)), 0L, 0L),
492+
0L, // requestedMaxReferencesPerNode 0 -> server decides
493+
Collections.singletonList(description));
494+
resultFuture = conversation.submit(request, BrowseResponse.class)
495+
.thenApply(response -> response.getResults().get(0));
496+
} else {
497+
BrowseNextRequest request = new BrowseNextRequest(
498+
conversation.createRequestHeader(),
499+
false, // don't release the continuation point, we want the next batch
500+
Collections.singletonList(continuationPoint));
501+
resultFuture = conversation.submit(request, BrowseNextResponse.class)
502+
.thenApply(response -> response.getResults().get(0));
503+
}
504+
return resultFuture.thenCompose(result -> {
505+
if (result.getReferences() != null) {
506+
accumulator.addAll(result.getReferences());
507+
}
508+
PascalByteString nextContinuationPoint = result.getContinuationPoint();
509+
if (nextContinuationPoint != null && nextContinuationPoint.getStringLength() > 0) {
510+
return browseReferences(nodeId, nextContinuationPoint, accumulator);
511+
}
512+
return CompletableFuture.completedFuture(accumulator);
513+
});
514+
}
515+
516+
private PlcBrowseItem buildBrowseItem(ReferenceDescription reference, String address, Map<String, PlcBrowseItem> children) {
517+
OpcuaTag tag = OpcuaTag.of(address);
518+
NodeClass nodeClass = reference.getNodeClass();
519+
boolean isVariable = nodeClass == NodeClass.nodeClassVariable;
520+
String name = localizedTextValue(reference.getDisplayName());
521+
if (name == null || name.isEmpty()) {
522+
name = qualifiedNameValue(reference.getBrowseName());
523+
}
524+
525+
Map<String, PlcValue> options = new HashMap<>();
526+
if (nodeClass != null) {
527+
options.put("node-class", new PlcSTRING(nodeClass.name()));
528+
}
529+
String browseName = qualifiedNameValue(reference.getBrowseName());
530+
if (browseName != null) {
531+
options.put("browse-name", new PlcSTRING(browseName));
532+
}
533+
534+
// Phase 1: only variable nodes are readable/writable. The actual access rights (and the
535+
// data type) are refined once server-side type resolution is wired in.
536+
return new DefaultPlcBrowseItem(tag, name, isVariable, isVariable,
537+
Collections.<PlcSubscriptionType>emptySet(), false, Collections.emptyList(), children, options);
538+
}
539+
540+
/** Formats the target node of a reference back into an OpcuaTag address (or null if unsupported). */
541+
private static String addressOf(ExpandedNodeId expandedNodeId) {
542+
if (expandedNodeId == null) {
543+
return null;
544+
}
545+
NodeIdTypeDefinition nodeId = expandedNodeId.getNodeId();
546+
String identifier = nodeId.getIdentifier();
547+
if (nodeId instanceof NodeIdTwoByte) {
548+
return "ns=0;i=" + identifier;
549+
} else if (nodeId instanceof NodeIdFourByte fourByte) {
550+
return "ns=" + fourByte.getNamespaceIndex() + ";i=" + identifier;
551+
} else if (nodeId instanceof NodeIdNumeric numeric) {
552+
return "ns=" + numeric.getNamespaceIndex() + ";i=" + identifier;
553+
} else if (nodeId instanceof NodeIdString string) {
554+
return "ns=" + string.getNamespaceIndex() + ";s=" + identifier;
555+
}
556+
// GUID and opaque (ByteString) node identifiers are not round-tripped in Phase 1:
557+
// NodeIdGuid#getIdentifier() returns the raw byte-array toString(), which the tag
558+
// parser can't turn back into a usable node id. Such nodes are skipped for now.
559+
return null;
560+
}
561+
562+
private static String localizedTextValue(LocalizedText localizedText) {
563+
if (localizedText == null || !localizedText.getTextSpecified() || localizedText.getText() == null) {
564+
return null;
565+
}
566+
return localizedText.getText().getStringValue();
567+
}
568+
569+
private static String qualifiedNameValue(QualifiedName qualifiedName) {
570+
if (qualifiedName == null || qualifiedName.getName() == null) {
571+
return null;
572+
}
573+
return qualifiedName.getName().getStringValue();
574+
}
575+
377576
public static PlcValue variantToPlcValue(PlcTag tag, Variant variant) {
378577
PlcValue value = null;
379578
if (variant instanceof VariantBoolean) {

plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaPlcDriver.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ protected boolean canSubscribe() {
7171
return true;
7272
}
7373

74+
@Override
75+
protected boolean canBrowse() {
76+
return true;
77+
}
78+
7479
@Override
7580
protected ConnectionBase<?> getConnection(Configuration configuration,
7681
TransportInstance<?> transportInstance,

plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/tag/OpcuaPlcTagHandler.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ public OpcuaTag parseTag(String tagAddress) {
3434

3535
@Override
3636
public PlcQuery parseQuery(String query) {
37-
throw new UnsupportedOperationException("This driver doesn't support browsing");
37+
// The query is the address of the node to start browsing from (empty => Objects folder).
38+
return new OpcuaQuery(query);
3839
}
3940

4041
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.plc4x.java.opcua.tag;
20+
21+
import org.apache.plc4x.java.api.model.PlcQuery;
22+
23+
/**
24+
* A browse query for the OPC UA driver. The query string is the address of the node to
25+
* start browsing from (e.g. {@code ns=2;s=HelloWorld}); an empty query starts at the
26+
* standard {@code Objects} folder.
27+
*/
28+
public class OpcuaQuery implements PlcQuery {
29+
30+
private final String queryString;
31+
32+
public OpcuaQuery(String queryString) {
33+
this.queryString = queryString;
34+
}
35+
36+
@Override
37+
public String getQueryString() {
38+
return queryString;
39+
}
40+
41+
@Override
42+
public String toString() {
43+
return "OpcuaQuery{" + queryString + '}';
44+
}
45+
46+
}

plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,70 @@ public void startUp() throws Exception {
212212
connectionStringValidSet = List.of(tcpConnectionAddress);
213213
}
214214

215+
@Test
216+
void browseWildcardDiscoversWholeAddressSpace() throws Exception {
217+
try (PlcConnection connection = new DefaultPlcDriverManager().getConnection(tcpConnectionAddress)) {
218+
// "**" is the "browse everything" wildcard: it starts at the Objects folder and
219+
// recurses the whole sub-tree.
220+
org.apache.plc4x.java.api.messages.PlcBrowseResponse response = connection.browseRequestBuilder()
221+
.addQuery("all", "**")
222+
.build().execute().get(60, TimeUnit.SECONDS);
223+
assertThat(response.getResponseCode("all")).isEqualTo(PlcResponseCode.OK);
224+
225+
java.util.List<org.apache.plc4x.java.api.messages.PlcBrowseItem> top = response.getValues("all");
226+
// The example server exposes the 'HelloWorld' folder under Objects.
227+
assertThat(top.stream().map(org.apache.plc4x.java.api.messages.PlcBrowseItem::getName)).contains("HelloWorld");
228+
229+
java.util.List<org.apache.plc4x.java.api.messages.PlcBrowseItem> all = new ArrayList<>();
230+
collectBrowseItems(top, all);
231+
assertThat(all.size()).isGreaterThan(50);
232+
}
233+
}
234+
235+
@Test
236+
void browseDiscoversAddressSpace() throws Exception {
237+
try (PlcConnection connection = new DefaultPlcDriverManager().getConnection(tcpConnectionAddress)) {
238+
assertThat(connection.getMetadata().isBrowseSupported()).isTrue();
239+
240+
org.apache.plc4x.java.api.messages.PlcBrowseResponse response = connection.browseRequestBuilder()
241+
.addQuery("hw", "ns=2;s=HelloWorld")
242+
.build().execute().get(30, TimeUnit.SECONDS);
243+
assertThat(response.getResponseCode("hw")).isEqualTo(PlcResponseCode.OK);
244+
245+
java.util.List<org.apache.plc4x.java.api.messages.PlcBrowseItem> top = response.getValues("hw");
246+
assertThat(top).isNotEmpty();
247+
248+
// 'ScalarTypes' is an Object (container) node: discovered, has children, not readable.
249+
org.apache.plc4x.java.api.messages.PlcBrowseItem scalarTypes = top.stream()
250+
.filter(i -> "ScalarTypes".equals(i.getName())).findFirst().orElse(null);
251+
assertThat(scalarTypes).as("ScalarTypes folder").isNotNull();
252+
assertThat(scalarTypes.isReadable()).isFalse();
253+
assertThat(scalarTypes.getChildren()).isNotEmpty();
254+
255+
// A concrete variable underneath it is readable and writable, with a usable address.
256+
org.apache.plc4x.java.api.messages.PlcBrowseItem boolVar = scalarTypes.getChildren().values().stream()
257+
.filter(i -> "Boolean".equals(i.getName())).findFirst().orElse(null);
258+
assertThat(boolVar).as("ScalarTypes/Boolean variable").isNotNull();
259+
assertThat(boolVar.isReadable()).isTrue();
260+
assertThat(boolVar.isWritable()).isTrue();
261+
assertThat(boolVar.getTag().getAddressString()).contains("ns=2;s=HelloWorld/ScalarTypes/Boolean");
262+
263+
// Full-subtree recursion reached deeply-nested property nodes (AnalogValue -> EURange).
264+
java.util.List<org.apache.plc4x.java.api.messages.PlcBrowseItem> all = new ArrayList<>();
265+
collectBrowseItems(top, all);
266+
assertThat(all).anyMatch(i -> "EURange".equals(i.getName()));
267+
assertThat(all.size()).isGreaterThan(40);
268+
}
269+
}
270+
271+
private static void collectBrowseItems(java.util.List<org.apache.plc4x.java.api.messages.PlcBrowseItem> items,
272+
java.util.List<org.apache.plc4x.java.api.messages.PlcBrowseItem> out) {
273+
for (org.apache.plc4x.java.api.messages.PlcBrowseItem item : items) {
274+
out.add(item);
275+
collectBrowseItems(new ArrayList<>(item.getChildren().values()), out);
276+
}
277+
}
278+
215279
@Nested
216280
class SmokeTest {
217281
@Test

0 commit comments

Comments
 (0)