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
The following commit(s) were added to refs/heads/develop by this push:
new ae3fd1e88d fix(plc4j/opcua): [Bug]: OPC-UA returns multiple identical
PlcSubscriptionHandles, one for every subscribed tag (Actually fixed this
phenomenon for all drivers supporting subscriptions)
ae3fd1e88d is described below
commit ae3fd1e88dc7886240bf9392feb67f8b23f48a00
Author: Christofer Dutz <[email protected]>
AuthorDate: Fri Aug 14 14:44:35 2026 +0200
fix(plc4j/opcua): [Bug]: OPC-UA returns multiple identical
PlcSubscriptionHandles, one for every subscribed tag (Actually fixed this
phenomenon for all drivers supporting subscriptions)
Fixes: #1896
---
.../apache/plc4x/java/opcua/OpcuaConnection.java | 199 +++++++++++----------
.../opcua/protocol/OpcuaSubscriptionHandle.java | 116 ++++++------
.../plc4x/java/opcua/OpcuaPlcDriverTest.java | 31 ++++
.../messages/DefaultPlcSubscriptionResponse.java | 9 +-
.../DefaultPlcSubscriptionResponseTest.java | 139 ++++++++++++++
5 files changed, 347 insertions(+), 147 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 632944549d..62f6d3c081 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
@@ -89,6 +89,9 @@ import java.util.stream.Collectors;
public class OpcuaConnection extends ConnectionBase<OpcuaConfiguration>
implements OpcuaWire {
private static final Logger LOGGER =
LoggerFactory.getLogger(OpcuaConnection.class);
+
+ /** Publishing interval used when a subscription tag doesn't ask for one.
*/
+ private static final Duration DEFAULT_CYCLE_TIME = Duration.ofMillis(1000);
public static final PascalString NULL_STRING = new PascalString(null);
private static final ExpandedNodeId NULL_EXPANDED_NODEID = new
ExpandedNodeId(false,
false,
@@ -551,7 +554,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
ReadRequest request = new
ReadRequest(conversation.createRequestHeader(), 0.0d,
TimestampsToReturn.timestampsToReturnNeither,
Collections.singletonList(dataTypeRead));
return conversation.submit(request,
ReadResponse.class).thenCompose(response -> {
- NodeId dataTypeNodeId =
dataTypeNodeIdOf(response.getResults().get(0));
+ NodeId dataTypeNodeId =
dataTypeNodeIdOf(response.getResults().getFirst());
if (dataTypeNodeId == null) {
return CompletableFuture.completedFuture(null);
}
@@ -566,7 +569,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
ReadRequest request = new
ReadRequest(conversation.createRequestHeader(), 0.0d,
TimestampsToReturn.timestampsToReturnNeither,
Collections.singletonList(definitionRead));
return conversation.submit(request,
ReadResponse.class).thenApply(response -> {
- DataValue value = response.getResults().get(0);
+ DataValue value = response.getResults().getFirst();
if (!value.getValueSpecified() || !(value.getValue() instanceof
VariantExtensionObject)) {
// Server doesn't expose the DataTypeDefinition attribute
(e.g. pre-1.04 servers
// return BadAttributeIdInvalid); the layout must come from
the legacy type
@@ -579,7 +582,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
}
// The DataTypeDefinition is a well-known standard type
(ns=0;i=101), so it is parsed
// into a typed BinaryExtensionObjectWithMask whose body is the
StructureDefinition.
- ExtensionObjectDefinition body = extensionObjects.get(0).getBody();
+ ExtensionObjectDefinition body =
extensionObjects.getFirst().getBody();
return (body instanceof StructureDefinition) ?
(StructureDefinition) body : null;
});
}
@@ -640,11 +643,11 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
new ViewDescription(new NodeId(new NodeIdTwoByte((short) 0)), 0L,
0L), 0L,
Collections.singletonList(description));
return conversation.submit(request,
BrowseResponse.class).thenApply(response -> {
- BrowseResult result = response.getResults().get(0);
+ BrowseResult result = response.getResults().getFirst();
if (result.getReferences() == null ||
result.getReferences().isEmpty()) {
return null;
}
- ExpandedNodeId target = result.getReferences().get(0).getNodeId();
+ ExpandedNodeId target =
result.getReferences().getFirst().getNodeId();
return target == null ? null : new NodeId(target.getNodeId());
});
}
@@ -654,7 +657,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
ReadValueId readValueId = new ReadValueId(node, attributeId,
NULL_STRING, new QualifiedName(0, NULL_STRING));
ReadRequest request = new
ReadRequest(conversation.createRequestHeader(), 0.0d,
TimestampsToReturn.timestampsToReturnNeither,
Collections.singletonList(readValueId));
- return conversation.submit(request,
ReadResponse.class).thenApply(response -> response.getResults().get(0));
+ return conversation.submit(request,
ReadResponse.class).thenApply(response -> response.getResults().getFirst());
}
private static String stringValueOf(DataValue dataValue) {
@@ -662,7 +665,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
return null;
}
List<PascalString> values = ((VariantString)
dataValue.getValue()).getValue();
- return (values == null || values.isEmpty()) ? null :
values.get(0).getStringValue();
+ return (values == null || values.isEmpty()) ? null :
values.getFirst().getStringValue();
}
private static byte[] byteStringValueOf(DataValue dataValue) {
@@ -673,7 +676,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
if (arrays == null || arrays.isEmpty()) {
return null;
}
- List<Short> bytes = arrays.get(0).getValue();
+ List<Short> bytes = arrays.getFirst().getValue();
byte[] result = new byte[bytes.size()];
for (int i = 0; i < bytes.size(); i++) {
result[i] = bytes.get(i).byteValue();
@@ -715,7 +718,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
Set<String> lengthFields = new HashSet<>();
for (int i = 0; i < fieldNodes.getLength(); i++) {
String lengthField = ((Element)
fieldNodes.item(i)).getAttribute("LengthField");
- if (lengthField != null && !lengthField.isEmpty()) {
+ if (!lengthField.isEmpty()) {
lengthFields.add(lengthField);
}
}
@@ -748,25 +751,24 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
return null;
}
String local = typeName.contains(":") ?
typeName.substring(typeName.indexOf(':') + 1) : typeName;
- switch (local) {
- case "Boolean": return 1;
- case "SByte": return 2;
- case "Byte": return 3;
- case "Int16": return 4;
- case "UInt16": return 5;
- case "Int32": return 6;
- case "UInt32": return 7;
- case "Int64": return 8;
- case "UInt64": return 9;
- case "Float": return 10;
- case "Double": return 11;
- case "String":
- case "CharArray": return 12;
- case "DateTime": return 13;
- case "Guid": return 14;
- case "ByteString": return 15;
- default: return null;
- }
+ return switch (local) {
+ case "Boolean" -> 1;
+ case "SByte" -> 2;
+ case "Byte" -> 3;
+ case "Int16" -> 4;
+ case "UInt16" -> 5;
+ case "Int32" -> 6;
+ case "UInt32" -> 7;
+ case "Int64" -> 8;
+ case "UInt64" -> 9;
+ case "Float" -> 10;
+ case "Double" -> 11;
+ case "String", "CharArray" -> 12;
+ case "DateTime" -> 13;
+ case "Guid" -> 14;
+ case "ByteString" -> 15;
+ default -> null;
+ };
}
/** Turns a struct Variant (single or array of ExtensionObjects) into a
PlcStruct / PlcList of PlcStruct. */
@@ -836,23 +838,22 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
int id = builtInDataTypeId == null ? -1 : builtInDataTypeId.intValue();
// Each OPC UA built-in is read with the smallest buffer reader that
covers its bit width
// (readUnsignedByte only handles 1-7 bits, so 8-bit and unsigned
values step up a size).
- switch (id) {
- case 1: return new PlcBOOL(buffer.readUnsignedShort(8) != 0);
- case 2: return new PlcSINT(buffer.readSignedByte(8));
- case 3: return new PlcUSINT(buffer.readUnsignedShort(8));
- case 4: return new PlcINT(buffer.readSignedShort(16));
- case 5: return new PlcUINT(buffer.readUnsignedInt(16));
- case 6: return new PlcDINT(buffer.readSignedInt(32));
- case 7: return new PlcUDINT(buffer.readUnsignedLong(32));
- case 8: return new PlcLINT(buffer.readSignedLong(64));
- case 9: return new PlcULINT(buffer.readUnsignedBigInteger(64));
- case 10: return new PlcREAL(buffer.readFloat(32));
- case 11: return new PlcLREAL(buffer.readDouble(64));
- case 12: return new
PlcSTRING(PascalString.staticParse(buffer).getStringValue());
- default:
- throw new PlcRuntimeException("Unsupported struct field data
type id " + id
- + " (nested structs, enums and non-builtin types are not
yet decoded)");
- }
+ return switch (id) {
+ case 1 -> new PlcBOOL(buffer.readUnsignedShort(8) != 0);
+ case 2 -> new PlcSINT(buffer.readSignedByte(8));
+ case 3 -> new PlcUSINT(buffer.readUnsignedShort(8));
+ case 4 -> new PlcINT(buffer.readSignedShort(16));
+ case 5 -> new PlcUINT(buffer.readUnsignedInt(16));
+ case 6 -> new PlcDINT(buffer.readSignedInt(32));
+ case 7 -> new PlcUDINT(buffer.readUnsignedLong(32));
+ case 8 -> new PlcLINT(buffer.readSignedLong(64));
+ case 9 -> new PlcULINT(buffer.readUnsignedBigInteger(64));
+ case 10 -> new PlcREAL(buffer.readFloat(32));
+ case 11 -> new PlcLREAL(buffer.readDouble(64));
+ case 12 -> new
PlcSTRING(PascalString.staticParse(buffer).getStringValue());
+ default -> throw new PlcRuntimeException("Unsupported struct field
data type id " + id
+ + " (nested structs, enums and non-builtin types are not yet
decoded)");
+ };
}
/** Canonical key for a NodeId (namespace + identifier) used to cache
resolved type layouts. */
@@ -952,7 +953,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
new ViewDescription(new NodeId(new NodeIdTwoByte((short) 0)), 0L,
0L), 0L,
Collections.singletonList(description));
return conversation.submit(request,
BrowseResponse.class).thenApply(response -> {
- List<ReferenceDescription> references =
response.getResults().get(0).getReferences();
+ List<ReferenceDescription> references =
response.getResults().getFirst().getReferences();
if (references == null || references.isEmpty()) {
return null;
}
@@ -961,7 +962,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
return reference.getNodeId();
}
}
- return references.get(0).getNodeId();
+ return references.getFirst().getNodeId();
});
}
@@ -974,7 +975,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
Collections.singletonList(extensionObject));
}
- /** Serialises a PlcStruct into the OPC UA binary body described by its
StructureDefinition. */
+ /** Serializes a PlcStruct into the OPC UA binary body described by its
StructureDefinition. */
private static byte[] encodeStructBody(PlcValue value, StructureDefinition
definition) {
try {
byte[] body = new byte[structBodySize(value, definition)];
@@ -1049,15 +1050,14 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
private static int scalarSize(Long builtInDataTypeId, PlcValue value) {
int id = builtInDataTypeId == null ? -1 : builtInDataTypeId.intValue();
- switch (id) {
- case 1: case 2: case 3: return 1;
- case 4: case 5: return 2;
- case 6: case 7: case 10: return 4;
- case 8: case 9: case 11: return 8;
- case 12: return new
PascalString(value.getString()).getLengthInBytes();
- default:
- throw new PlcRuntimeException("Unsupported struct field data
type id " + id + " for writing");
- }
+ return switch (id) {
+ case 1, 2, 3 -> 1;
+ case 4, 5 -> 2;
+ case 6, 7, 10 -> 4;
+ case 8, 9, 11 -> 8;
+ case 12 -> new PascalString(value.getString()).getLengthInBytes();
+ default -> throw new PlcRuntimeException("Unsupported struct field
data type id " + id + " for writing");
+ };
}
// The standard "Objects" folder — the usual entry point into a server's
address space.
@@ -1184,14 +1184,14 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
0L, // requestedMaxReferencesPerNode 0 ->
server decides
Collections.singletonList(description));
resultFuture = conversation.submit(request, BrowseResponse.class)
- .thenApply(response -> response.getResults().get(0));
+ .thenApply(response -> response.getResults().getFirst());
} else {
BrowseNextRequest request = new BrowseNextRequest(
conversation.createRequestHeader(),
false, // don't release the continuation point,
we want the next batch
Collections.singletonList(continuationPoint));
resultFuture = conversation.submit(request,
BrowseNextResponse.class)
- .thenApply(response -> response.getResults().get(0));
+ .thenApply(response -> response.getResults().getFirst());
}
return resultFuture.thenCompose(result -> {
if (result.getReferences() != null) {
@@ -1249,7 +1249,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
}
return new DefaultPlcBrowseItem(tag, name, readable, writable,
- Collections.<PlcSubscriptionType>emptySet(), false, arrayInfo,
children, options);
+ Collections.emptySet(), false, arrayInfo, children, options);
}
// The node attributes read for every variable child during a browse so
items carry a real
@@ -1437,7 +1437,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
return null;
}
List<NodeId> nodeIds = ((VariantNodeId)
dataValue.getValue()).getValue();
- return (nodeIds == null || nodeIds.isEmpty()) ? null : nodeIds.get(0);
+ return (nodeIds == null || nodeIds.isEmpty()) ? null :
nodeIds.getFirst();
}
/**
@@ -1501,7 +1501,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
return null;
}
List<Short> values = ((VariantByte) dataValue.getValue()).getValue();
- return (values == null || values.isEmpty()) ? null : values.get(0);
+ return (values == null || values.isEmpty()) ? null : values.getFirst();
}
/** Extracts the first Int32 from a Variant, or null if not an int32
scalar. */
@@ -1510,7 +1510,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
return null;
}
List<Integer> values = ((VariantInt32)
dataValue.getValue()).getValue();
- return (values == null || values.isEmpty()) ? null : values.get(0);
+ return (values == null || values.isEmpty()) ? null : values.getFirst();
}
/** Extracts the UInt32 list from a Variant, or null if not a uint32
value. */
@@ -1557,19 +1557,17 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
}
NodeIdTypeDefinition nodeId = expandedNodeId.getNodeId();
String identifier = nodeId.getIdentifier();
- if (nodeId instanceof NodeIdTwoByte) {
- return "ns=0;i=" + identifier;
- } else if (nodeId instanceof NodeIdFourByte fourByte) {
- return "ns=" + fourByte.getNamespaceIndex() + ";i=" + identifier;
- } else if (nodeId instanceof NodeIdNumeric numeric) {
- return "ns=" + numeric.getNamespaceIndex() + ";i=" + identifier;
- } else if (nodeId instanceof NodeIdString string) {
- return "ns=" + string.getNamespaceIndex() + ";s=" + identifier;
- }
- // GUID and opaque (ByteString) node identifiers are not round-tripped
in Phase 1:
- // NodeIdGuid#getIdentifier() returns the raw byte-array toString(),
which the tag
- // parser can't turn back into a usable node id. Such nodes are
skipped for now.
- return null;
+ return switch (nodeId) {
+ case NodeIdTwoByte ignored -> "ns=0;i=" + identifier;
+ case NodeIdFourByte fourByte -> "ns=" +
fourByte.getNamespaceIndex() + ";i=" + identifier;
+ case NodeIdNumeric numeric -> "ns=" + numeric.getNamespaceIndex()
+ ";i=" + identifier;
+ case NodeIdString string -> "ns=" + string.getNamespaceIndex() +
";s=" + identifier;
+ default ->
+ // GUID and opaque (ByteString) node identifiers are not
round-tripped in Phase 1:
+ // NodeIdGuid#getIdentifier() returns the raw byte-array
toString(), which the tag
+ // parser can't turn back into a usable node id. Such nodes
are skipped for now.
+ null;
+ };
}
private static String localizedTextValue(LocalizedText localizedText) {
@@ -1595,7 +1593,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
case null -> {
return new PlcNull();
}
- case VariantNull variantNull -> {
+ case VariantNull ignored -> {
return new PlcNull();
}
case VariantBoolean variantBoolean -> {
@@ -2036,9 +2034,9 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
} else if (serverAttributes != null &&
serverAttributes.unsigned()) {
// Node is the abstract UInteger type: no concrete built-in to
resolve to, so pick
// an unsigned type sized to the value (an abstract node
accepts any subtype).
- dataType = inferUnsignedWriteType(plcValueList.get(0));
+ dataType = inferUnsignedWriteType(plcValueList.getFirst());
} else {
- dataType = inferWriteType(plcValueList.get(0));
+ dataType = inferWriteType(plcValueList.getFirst());
}
}
int length = valueObject.getLength();
@@ -2390,12 +2388,24 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
@Override
protected CompletableFuture<PlcSubscriptionResponse>
onSubscribe(PlcSubscriptionRequest subscriptionRequest) {
List<String> tagNames = new
ArrayList<>(subscriptionRequest.getTagNames());
- long cycleTime =
(subscriptionRequest.getTag(tagNames.get(0))).getDuration().orElse(Duration.ofMillis(1000)).toMillis();
+ // A subscription has a single publishing interval, so take the
fastest rate any of its tags
+ // asked for - taking the first tag's rate would starve every faster
tag behind it. The
+ // individual sampling rates are set per monitored item (see
onSubscribeCreateMonitoredItemsRequest).
+ long cycleTime = tagNames.stream()
+ .map(subscriptionRequest::getTag)
+ .map(tag -> tag.getDuration().orElse(DEFAULT_CYCLE_TIME))
+ .min(Duration::compareTo)
+ .orElse(DEFAULT_CYCLE_TIME)
+ .toMillis();
return onSubscribeCreateSubscription(cycleTime).thenApply(response -> {
long subscriptionId = response.getSubscriptionId();
+ // The server may revise the publishing interval; everything
we time against it
+ // (the publish request cadence and its timeouts) has to
follow the revised value,
+ // not what we asked for.
+ long revisedCycleTime = revisedPublishingInterval(response,
cycleTime);
OpcuaSubscriptionHandle handle = new
OpcuaSubscriptionHandle(this,
- conversation, subscriptionRequest, subscriptionId,
cycleTime);
+ conversation, subscriptionRequest, subscriptionId,
cycleTime, revisedCycleTime);
if (subscriptionRequest.getConsumer() != null) {
handle.register(subscriptionRequest.getConsumer());
}
@@ -2408,7 +2418,7 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
subscriptions.put(handle.getSubscriptionId(), handle);
return handle;
})
- .thenCompose(handle ->
handle.onSubscribeCreateMonitoredItemsRequest())
+
.thenCompose(OpcuaSubscriptionHandle::onSubscribeCreateMonitoredItemsRequest)
.thenApply(handle -> {
Map<String, PlcResponseItem<PlcSubscriptionHandle>> values =
new HashMap<>();
for (String tagName : subscriptionRequest.getTagNames()) {
@@ -2424,6 +2434,23 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
});
}
+ /**
+ * The publishing interval the server actually granted, in milliseconds.
Servers are free to
+ * revise the interval we requested (typically upwards, to their smallest
supported rate).
+ * Falls back to the requested value if the server reports something
unusable.
+ */
+ private static long revisedPublishingInterval(CreateSubscriptionResponse
response, long requestedCycleTime) {
+ double revised = response.getRevisedPublishingInterval();
+ if (revised <= 0) {
+ return requestedCycleTime;
+ }
+ long revisedMillis = Math.round(revised);
+ if (revisedMillis != requestedCycleTime) {
+ LOGGER.debug("Server revised the publishing interval from {}ms to
{}ms", requestedCycleTime, revisedMillis);
+ }
+ return revisedMillis;
+ }
+
private CompletableFuture<CreateSubscriptionResponse>
onSubscribeCreateSubscription(long cycleTime) {
LOGGER.trace("Entering creating subscription request");
@@ -2477,12 +2504,4 @@ public class OpcuaConnection extends
ConnectionBase<OpcuaConfiguration> implemen
return (dateTime - EPOCH_OFFSET) / 10000;
}
- private GuidValue toGuidValue(String identifier) {
- LOGGER.error("Querying Guid nodes is not supported");
- byte[] data4 = new byte[]{0, 0};
- byte[] data5 = new byte[]{0, 0, 0, 0, 0, 0};
- return new GuidValue(0L, 0, 0, data4, data5);
- }
-
-
}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/protocol/OpcuaSubscriptionHandle.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/protocol/OpcuaSubscriptionHandle.java
index eb71c81df7..a9c82da9f6 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/protocol/OpcuaSubscriptionHandle.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/protocol/OpcuaSubscriptionHandle.java
@@ -20,15 +20,12 @@ package org.apache.plc4x.java.opcua.protocol;
import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor;
-import java.util.Map.Entry;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
-import org.apache.plc4x.java.api.messages.PlcMetadataKeys;
import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent;
import org.apache.plc4x.java.api.messages.PlcSubscriptionRequest;
-import org.apache.plc4x.java.api.metadata.Metadata;
import org.apache.plc4x.java.api.types.PlcResponseCode;
import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem;
import org.apache.plc4x.java.api.model.PlcConsumerRegistration;
@@ -49,6 +46,7 @@ import org.apache.plc4x.java.spi.values.PlcStruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@@ -78,6 +76,17 @@ public class OpcuaSubscriptionHandle implements
PlcSubscriptionHandle {
public OpcuaSubscriptionHandle(OpcuaConnection plcSubscriber,
Conversation conversation, PlcSubscriptionRequest subscriptionRequest,
Long subscriptionId, long cycleTime) {
+ this(plcSubscriber, conversation, subscriptionRequest, subscriptionId,
cycleTime, cycleTime);
+ }
+
+ /**
+ * @param cycleTime the publishing interval that was requested
+ * @param revisedCycleTime the publishing interval the server granted; the
publish request
+ * cadence and its timeouts are derived from this
one
+ */
+ public OpcuaSubscriptionHandle(OpcuaConnection plcSubscriber,
+ Conversation conversation, PlcSubscriptionRequest subscriptionRequest,
Long subscriptionId,
+ long cycleTime, long revisedCycleTime) {
this.consumers = new HashSet<>();
this.tagConsumers = new HashMap<>();
this.subscriptionRequest = subscriptionRequest;
@@ -86,7 +95,7 @@ public class OpcuaSubscriptionHandle implements
PlcSubscriptionHandle {
this.subscriptionId = subscriptionId;
this.plcSubscriber = plcSubscriber;
this.cycleTime = cycleTime;
- this.revisedCycleTime = cycleTime;
+ this.revisedCycleTime = revisedCycleTime;
}
public CompletableFuture<OpcuaSubscriptionHandle>
onSubscribeCreateMonitoredItemsRequest() {
@@ -135,9 +144,14 @@ public class OpcuaSubscriptionHandle implements
PlcSubscriptionHandle {
}
long clientHandle = clientHandles.getAndIncrement();
+ // Each monitored item is sampled at the rate its own tag asked
for. Using the
+ // subscription's cycle time for every item would silently give
all tags the rate of
+ // whichever tag happened to come first - see the discussion in
GH-1896.
+ double samplingInterval = tagDefaultPlcSubscription.getDuration()
+ .map(Duration::toMillis).orElse(cycleTime);
MonitoringParameters parameters = new MonitoringParameters(
clientHandle,
- (double) cycleTime, // sampling interval
+ samplingInterval,
eventFilter, // filter, null means use default
1L, // queue size
true // discard oldest
@@ -164,7 +178,7 @@ public class OpcuaSubscriptionHandle implements
PlcSubscriptionHandle {
logger.info("Error while sending the Create Monitored Item
Subscription Message", error);
}
}).thenApply(responseMessage -> {
- MonitoredItemCreateResult[] array =
responseMessage.getResults().stream().toArray(MonitoredItemCreateResult[]::new);
+ MonitoredItemCreateResult[] array =
responseMessage.getResults().toArray(MonitoredItemCreateResult[]::new);
for (int index = 0, arrayLength = array.length; index <
arrayLength; index++) {
MonitoredItemCreateResult result = array[index];
if
(OpcuaStatusCode.enumForValue(result.getStatusCode().getStatusCode()) !=
OpcuaStatusCode.Good) {
@@ -186,59 +200,49 @@ public class OpcuaSubscriptionHandle implements
PlcSubscriptionHandle {
* The server will respond at most once every cycle.
*/
private void sendPublishRequest() {
- List<Long> outstandingRequests = new LinkedList<>();
-
- //If we are waiting on a response and haven't received one, just wait
until we do. A keep alive will be sent out eventually
- if (outstandingRequests.size() <= 1) {
- RequestHeader requestHeader =
conversation.createRequestHeader(this.revisedCycleTime * 10);
-
- //Make a copy of the outstanding requests, so it isn't modified
while we are putting the ack list together.
- List<SubscriptionAcknowledgement> acks = new
ArrayList<>(outstandingAcknowledgements);
- // do not send -1 when requesting publish, the -1 value indicates
NULL value
- // which might result in corruption of subscription for some
servers
- int ackLength = acks.size();
- outstandingAcknowledgements.removeAll(acks);
-
- PublishRequest publishRequest = new PublishRequest(requestHeader,
acks);
- logger.trace("Sent publish request with {} acks", ackLength);
- // Create Consumer for the response message, error and timeout to
be sent to the Secure Channel
- conversation.submit(publishRequest,
PublishResponse.class).thenAccept(responseMessage -> {
-
outstandingRequests.remove(responseMessage.getResponseHeader().getRequestHandle());
-
- for (long availableSequenceNumber :
responseMessage.getAvailableSequenceNumbers()) {
- outstandingAcknowledgements.add(new
SubscriptionAcknowledgement(this.subscriptionId, availableSequenceNumber));
- }
+ RequestHeader requestHeader =
conversation.createRequestHeader(this.revisedCycleTime * 10);
+
+ //Make a copy of the outstanding requests, so it isn't modified while
we are putting the ack list together.
+ List<SubscriptionAcknowledgement> acks = new
ArrayList<>(outstandingAcknowledgements);
+ // do not send -1 when requesting publish, the -1 value indicates NULL
value
+ // which might result in corruption of subscription for some servers
+ int ackLength = acks.size();
+ outstandingAcknowledgements.removeAll(acks);
- NotificationMessage message =
responseMessage.getNotificationMessage();
- if (message.getNotificationData() != null) {
- for (ExtensionObject notificationMessage :
message.getNotificationData()) {
- ExtensionObjectDefinition notification =
notificationMessage.getBody();
- if (notification instanceof
DataChangeNotification) {
- logger.trace("Found a Data Change
Notification");
- DataChangeNotification data =
(DataChangeNotification) notification;
- if (!data.getMonitoredItems().isEmpty()) {
- onMonitoredValue(data.getMonitoredItems());
- }
- } else if (notification instanceof
EventNotificationList) {
- logger.trace("Found a Event Notification");
- EventNotificationList data =
(EventNotificationList) notification;
- if (!data.getEvents().isEmpty()) {
- onEventNotification(data.getEvents());
- }
- } else {
- logger.warn("Unsupported Notification type
{}", notification.getClass().getName());
- }
+ PublishRequest publishRequest = new PublishRequest(requestHeader,
acks);
+ logger.trace("Sent publish request with {} acks", ackLength);
+ // Create Consumer for the response message, error and timeout to be
sent to the Secure Channel
+ conversation.submit(publishRequest,
PublishResponse.class).thenAccept(responseMessage -> {
+ for (long availableSequenceNumber :
responseMessage.getAvailableSequenceNumbers()) {
+ outstandingAcknowledgements.add(new
SubscriptionAcknowledgement(this.subscriptionId, availableSequenceNumber));
+ }
+
+ NotificationMessage message =
responseMessage.getNotificationMessage();
+ if (message.getNotificationData() != null) {
+ for (ExtensionObject notificationMessage :
message.getNotificationData()) {
+ ExtensionObjectDefinition notification =
notificationMessage.getBody();
+ if (notification instanceof DataChangeNotification data) {
+ logger.trace("Found a Data Change Notification");
+ if (!data.getMonitoredItems().isEmpty()) {
+ onMonitoredValue(data.getMonitoredItems());
+ }
+ } else if (notification instanceof EventNotificationList
data) {
+ logger.trace("Found a Event Notification");
+ if (!data.getEvents().isEmpty()) {
+ onEventNotification(data.getEvents());
}
+ } else {
+ logger.warn("Unsupported Notification type {}",
notification.getClass().getName());
}
- }).whenComplete((result, error) -> {
- if (error != null) {
- logger.warn("Publish request of subscription {} resulted
in error reported by server", subscriptionId, error);
- } else {
- logger.trace("Completed publish request for subscription
{}", subscriptionId);
}
- });
- outstandingRequests.add(requestHeader.getRequestHandle());
- }
+ }
+ }).whenComplete((result, error) -> {
+ if (error != null) {
+ logger.warn("Publish request of subscription {} resulted in
error reported by server", subscriptionId, error);
+ } else {
+ logger.trace("Completed publish request for subscription {}",
subscriptionId);
+ }
+ });
}
@@ -337,7 +341,7 @@ public class OpcuaSubscriptionHandle implements
PlcSubscriptionHandle {
}
public PlcConsumerRegistration registerTagConsumer(String tagName,
Consumer<PlcSubscriptionEvent> consumer) {
- logger.info("Registering a new OPCUA subscription consumer for tag
with name " + tagName);
+ logger.info("Registering a new OPCUA subscription consumer for tag
with name {}", tagName);
tagConsumers.put(tagName, consumer);
return new DefaultPlcConsumerRegistration(plcSubscriber, consumer,
this);
}
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 dc2fcdea7c..9f86217060 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
@@ -676,6 +676,37 @@ public class OpcuaPlcDriverTest {
}
}
}
+ /**
+ * A subscription covering several tags is served by a single handle,
so
+ * getSubscriptionHandles() has to report exactly one - see GH-1896.
Reporting it once per
+ * tag makes the documented "register a consumer on every handle" loop
attach N consumers
+ * and deliver every event N times.
+ */
+ @Test
+ public void multiTagSubscriptionReportsOneHandle() throws Exception {
+ PlcConnectionManager connectionManager = new
DefaultPlcDriverManager().getConnectionManager();
+
+ try (PlcConnection connection =
connectionManager.getConnection(tcpConnectionAddress)) {
+ PlcSubscriptionRequest request =
connection.subscriptionRequestBuilder()
+ .addChangeOfStateTag("first",
OpcuaTag.of(INTEGER_IDENTIFIER_READ_WRITE))
+ .addChangeOfStateTag("second",
OpcuaTag.of(BOOL_IDENTIFIER_READ_WRITE))
+ .addChangeOfStateTag("third",
OpcuaTag.of(INT32_IDENTIFIER_READ_WRITE))
+ .build();
+
+ PlcSubscriptionResponse response = request.execute().get(60,
TimeUnit.SECONDS);
+
assertThat(response.getResponseCode("first")).isEqualTo(PlcResponseCode.OK);
+
assertThat(response.getResponseCode("second")).isEqualTo(PlcResponseCode.OK);
+
assertThat(response.getResponseCode("third")).isEqualTo(PlcResponseCode.OK);
+
+ assertThat(response.getSubscriptionHandles()).hasSize(1);
+
+ connection.unsubscriptionRequestBuilder()
+ .addHandles(response.getSubscriptionHandles())
+ .build()
+ .execute();
+ }
+ }
+
@Test
public void manySubscriptionsOnSingleConnection() throws Exception {
final int numberOfSubscriptions = 3;
diff --git
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcSubscriptionResponse.java
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcSubscriptionResponse.java
index ae8b2251be..b50c35ad1f 100644
---
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcSubscriptionResponse.java
+++
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcSubscriptionResponse.java
@@ -71,9 +71,16 @@ public class DefaultPlcSubscriptionResponse implements
PlcSubscriptionResponse {
@Override
public Collection<PlcSubscriptionHandle> getSubscriptionHandles() {
+ // A single handle can cover several tags - drivers commonly create
one handle for the whole
+ // request and map it to every tag name. Returning it once per tag
would make the usual
+ // "register a consumer on every handle" loop deliver each event N
times, see GH-1896.
+ // Duplicates are detected by identity: two distinct handles that
happen to compare equal
+ // are still two subscriptions.
+ Map<PlcSubscriptionHandle, Boolean> seen = new IdentityHashMap<>();
List<PlcSubscriptionHandle> handles = new ArrayList<>(values.size());
for (PlcResponseItem<PlcSubscriptionHandle> item : values.values()) {
- if (item != null && item.getValue() != null) {
+ if (item != null && item.getValue() != null
+ && seen.put(item.getValue(), Boolean.TRUE) == null) {
handles.add(item.getValue());
}
}
diff --git
a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcSubscriptionResponseTest.java
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcSubscriptionResponseTest.java
new file mode 100644
index 0000000000..906a0ef6d5
--- /dev/null
+++
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcSubscriptionResponseTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.spi.drivers.messages;
+
+import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionRequest;
+import org.apache.plc4x.java.api.model.PlcConsumerRegistration;
+import org.apache.plc4x.java.api.model.PlcSubscriptionHandle;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DefaultPlcSubscriptionResponseTest {
+
+ /**
+ * Drivers commonly create one handle for the whole request and map it to
every tag name.
+ * getSubscriptionHandles() must report that handle once, otherwise the
usual "register a
+ * consumer on every handle" loop delivers each event once per tag - see
GH-1896.
+ */
+ @Test
+ void oneHandleSharedByManyTagsIsReportedOnce() {
+ PlcSubscriptionHandle handle = new TestHandle();
+ Map<String, PlcResponseItem<PlcSubscriptionHandle>> values = new
LinkedHashMap<>();
+ for (int i = 0; i < 5; i++) {
+ values.put("tag" + i, new
DefaultPlcResponseItem<>(PlcResponseCode.OK, handle));
+ }
+
+ Collection<PlcSubscriptionHandle> handles =
response(values).getSubscriptionHandles();
+
+ assertEquals(1, handles.size());
+ assertSame(handle, handles.iterator().next());
+ }
+
+ /**
+ * Distinct handles must all survive, even when they compare equal - two
subscriptions are two
+ * subscriptions.
+ */
+ @Test
+ void distinctHandlesAreAllReported() {
+ PlcSubscriptionHandle first = new AlwaysEqualHandle();
+ PlcSubscriptionHandle second = new AlwaysEqualHandle();
+ Map<String, PlcResponseItem<PlcSubscriptionHandle>> values = new
LinkedHashMap<>();
+ values.put("a", new DefaultPlcResponseItem<>(PlcResponseCode.OK,
first));
+ values.put("b", new DefaultPlcResponseItem<>(PlcResponseCode.OK,
second));
+
+ Collection<PlcSubscriptionHandle> handles =
response(values).getSubscriptionHandles();
+
+ assertEquals(2, handles.size());
+ assertTrue(handles.stream().anyMatch(h -> h == first));
+ assertTrue(handles.stream().anyMatch(h -> h == second));
+ }
+
+ /**
+ * A mix: two tags share one handle, a third has its own.
+ */
+ @Test
+ void mixedSharedAndDistinctHandles() {
+ PlcSubscriptionHandle shared = new TestHandle();
+ PlcSubscriptionHandle own = new TestHandle();
+ Map<String, PlcResponseItem<PlcSubscriptionHandle>> values = new
LinkedHashMap<>();
+ values.put("a", new DefaultPlcResponseItem<>(PlcResponseCode.OK,
shared));
+ values.put("b", new DefaultPlcResponseItem<>(PlcResponseCode.OK,
shared));
+ values.put("c", new DefaultPlcResponseItem<>(PlcResponseCode.OK, own));
+
+ assertEquals(2, response(values).getSubscriptionHandles().size());
+ }
+
+ /**
+ * Tags that failed to subscribe carry a null handle and must simply be
skipped.
+ */
+ @Test
+ void tagsWithoutAHandleAreSkipped() {
+ PlcSubscriptionHandle handle = new TestHandle();
+ Map<String, PlcResponseItem<PlcSubscriptionHandle>> values = new
LinkedHashMap<>();
+ values.put("ok", new DefaultPlcResponseItem<>(PlcResponseCode.OK,
handle));
+ values.put("bad", new
DefaultPlcResponseItem<>(PlcResponseCode.INVALID_ADDRESS, null));
+
+ assertEquals(1, response(values).getSubscriptionHandles().size());
+ }
+
+ private DefaultPlcSubscriptionResponse response(Map<String,
PlcResponseItem<PlcSubscriptionHandle>> values) {
+ PlcSubscriptionRequest request =
Mockito.mock(PlcSubscriptionRequest.class);
+ Mockito.lenient().when(request.getTagNames()).thenReturn(new
LinkedHashSet<>(values.keySet()));
+ return new DefaultPlcSubscriptionResponse(request, values);
+ }
+
+ private static class TestHandle implements PlcSubscriptionHandle {
+ @Override
+ public PlcConsumerRegistration register(Consumer<PlcSubscriptionEvent>
consumer) {
+ return null;
+ }
+ }
+
+ /** Compares equal to every other instance, to prove de-duplication is by
identity. */
+ private static class AlwaysEqualHandle implements PlcSubscriptionHandle {
+ @Override
+ public PlcConsumerRegistration register(Consumer<PlcSubscriptionEvent>
consumer) {
+ return null;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj instanceof AlwaysEqualHandle;
+ }
+
+ @Override
+ public int hashCode() {
+ return 1;
+ }
+ }
+}