|
27 | 27 | import java.util.concurrent.ConcurrentHashMap; |
28 | 28 | import java.util.concurrent.CopyOnWriteArrayList; |
29 | 29 | import java.util.concurrent.TimeUnit; |
30 | | -import java.util.concurrent.TimeoutException; |
31 | 30 | import org.apache.plc4x.java.api.authentication.PlcAuthentication; |
32 | 31 | import org.apache.plc4x.java.api.authentication.PlcUsernamePasswordAuthentication; |
33 | 32 | import org.apache.plc4x.java.api.exceptions.PlcConnectionException; |
34 | 33 | import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; |
35 | 34 | 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; |
38 | 35 | import org.apache.plc4x.java.api.model.PlcConsumerRegistration; |
| 36 | +import org.apache.plc4x.java.api.model.PlcQuery; |
39 | 37 | import org.apache.plc4x.java.api.model.PlcSubscriptionHandle; |
40 | 38 | import org.apache.plc4x.java.api.model.PlcTag; |
41 | 39 | import org.apache.plc4x.java.api.types.PlcResponseCode; |
| 40 | +import org.apache.plc4x.java.api.types.PlcSubscriptionType; |
42 | 41 | import org.apache.plc4x.java.api.types.PlcValueType; |
43 | 42 | import org.apache.plc4x.java.api.value.PlcValue; |
44 | 43 | import org.apache.plc4x.java.opcua.config.OpcuaConfiguration; |
|
49 | 48 | import org.apache.plc4x.java.opcua.protocol.OpcuaSubscriptionHandle; |
50 | 49 | import org.apache.plc4x.java.opcua.readwrite.*; |
51 | 50 | 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; |
53 | 52 | import org.apache.plc4x.java.opcua.tag.OpcuaTag; |
54 | | -import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException; |
55 | 53 | import org.apache.plc4x.java.spi.drivers.ConnectionBase; |
56 | 54 | import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException; |
57 | 55 | import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler; |
|
74 | 72 | import java.util.concurrent.CompletableFuture; |
75 | 73 | import java.util.function.Consumer; |
76 | 74 | import java.util.function.Predicate; |
| 75 | +import java.util.stream.Collectors; |
77 | 76 |
|
78 | 77 | public class OpcuaConnection extends ConnectionBase<OpcuaConfiguration> implements OpcuaWire { |
79 | 78 |
|
@@ -374,6 +373,206 @@ public Map<String, PlcResponseItem<PlcValue>> readResponse(Map<String, PlcTag> t |
374 | 373 | return response; |
375 | 374 | } |
376 | 375 |
|
| 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 | + |
377 | 576 | public static PlcValue variantToPlcValue(PlcTag tag, Variant variant) { |
378 | 577 | PlcValue value = null; |
379 | 578 | if (variant instanceof VariantBoolean) { |
|
0 commit comments