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 1e1edeffcb fix(plc4j/opcua): [Bug]: plc4j-driver-opcua - No 
subscription events are generated for addCyclicField(...) tags
1e1edeffcb is described below

commit 1e1edeffcbfecaf80873a4ec9b67f83c44fd0b2f
Author: Christofer Dutz <[email protected]>
AuthorDate: Fri Aug 14 15:05:22 2026 +0200

    fix(plc4j/opcua): [Bug]: plc4j-driver-opcua - No subscription events are 
generated for addCyclicField(...) tags
    
    Implemented an cyclic-change emulation layer for OPC-UA as OPC-UA generally 
doesn't support cyclic subscriptions
    
    Fixes: #1102:
---
 .../opcua/protocol/OpcuaSubscriptionHandle.java    | 80 +++++++++++++++++++++-
 .../plc4x/java/opcua/OpcuaPlcDriverTest.java       | 39 +++++++++++
 2 files changed, 118 insertions(+), 1 deletion(-)

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 a9c82da9f6..64fca8cbb2 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,6 +20,7 @@ package org.apache.plc4x.java.opcua.protocol;
 
 import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor;
 
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
@@ -41,6 +42,7 @@ import 
org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem;
 import 
org.apache.plc4x.java.spi.drivers.messages.DefaultPlcConsumerRegistration;
 import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcSubscriptionTag;
 import org.apache.plc4x.java.api.model.PlcSubscriptionHandle;
+import org.apache.plc4x.java.api.model.PlcSubscriptionTag;
 import org.apache.plc4x.java.spi.values.PlcNull;
 import org.apache.plc4x.java.spi.values.PlcStruct;
 import org.slf4j.Logger;
@@ -74,6 +76,10 @@ public class OpcuaSubscriptionHandle implements 
PlcSubscriptionHandle {
     private final List<SubscriptionAcknowledgement> 
outstandingAcknowledgements = new CopyOnWriteArrayList<>();
     private ScheduledFuture<?> publishTask;
 
+    /** Most recent value per tag, used to re-report CYCLIC tags on their own 
schedule. */
+    private final Map<String, PlcResponseItem<PlcValue>> lastValues = new 
ConcurrentHashMap<>();
+    private final List<ScheduledFuture<?>> cyclicTasks = new 
CopyOnWriteArrayList<>();
+
     public OpcuaSubscriptionHandle(OpcuaConnection plcSubscriber,
         Conversation conversation, PlcSubscriptionRequest subscriptionRequest, 
Long subscriptionId, long cycleTime) {
         this(plcSubscriber, conversation, subscriptionRequest, subscriptionId, 
cycleTime, cycleTime);
@@ -190,6 +196,7 @@ public class OpcuaSubscriptionHandle implements 
PlcSubscriptionHandle {
 
                 logger.trace("Scheduling publish event for subscription {}", 
subscriptionId);
                 publishTask = 
EXECUTOR.scheduleAtFixedRate(this::sendPublishRequest, revisedCycleTime / 2, 
revisedCycleTime, TimeUnit.MILLISECONDS);
+                startCyclicEmitters();
                 return this;
             });
     }
@@ -256,15 +263,26 @@ public class OpcuaSubscriptionHandle implements 
PlcSubscriptionHandle {
 
         //  Create Consumer for the response message, error and timeout to be 
sent to the Secure Channel
         conversation.submit(deleteSubscriptionRequest, 
DeleteSubscriptionsResponse.class)
-            .thenAccept(responseMessage -> publishTask.cancel(true))
             .whenComplete((result, error) -> {
                 if (error != null) {
                     logger.error("Deletion of subscription resulted in error", 
error);
                 }
+                // Stop our own scheduled work regardless of how the server 
answered - a failed
+                // delete must not leave the publish loop and the cyclic 
emitters running.
+                cancelScheduledTasks();
                 plcSubscriber.removeSubscription(subscriptionId);
             });
     }
 
+    private void cancelScheduledTasks() {
+        if (publishTask != null) {
+            publishTask.cancel(true);
+            publishTask = null;
+        }
+        cyclicTasks.forEach(task -> task.cancel(true));
+        cyclicTasks.clear();
+    }
+
     /**
      * Receive the returned values from the OPCUA server and format it so that 
it can be received by the PLC4X client.
      *
@@ -289,10 +307,70 @@ public class OpcuaSubscriptionHandle implements 
PlcSubscriptionHandle {
         }
 
         Map<String, PlcResponseItem<PlcValue>> mappedResponse = 
plcSubscriber.readResponse(tagMap, dataValues);
+        // Remember the values so cyclic tags can be reported again on their 
own schedule even
+        // though the server only notifies us when something actually changes 
- see GH-1102.
+        lastValues.putAll(mappedResponse);
         PlcSubscriptionEvent event = new 
DefaultPlcSubscriptionEvent(Instant.ofEpochMilli(receiveTs), mappedResponse);
         consumers.forEach(plcSubscriptionEventConsumer -> 
plcSubscriptionEventConsumer.accept(event));
     }
 
+    /**
+     * Starts the emitters for CYCLIC tags.
+     * <p>
+     * OPC UA has no notion of a polling interval: a monitored item reports 
its initial value and
+     * then only reports again when the value changes. A CYCLIC subscription 
however promises an
+     * event every interval, so for those tags we re-report the most recently 
received value on
+     * the requested schedule (see GH-1102). Tags sharing an interval are 
reported together.
+     */
+    private void startCyclicEmitters() {
+        Map<Long, List<String>> tagsByInterval = new LinkedHashMap<>();
+        for (String tagName : tagNames) {
+            PlcSubscriptionTag tag = subscriptionRequest.getTag(tagName);
+            if (tag.getPlcSubscriptionType() != PlcSubscriptionType.CYCLIC) {
+                continue;
+            }
+            long interval = 
tag.getDuration().map(Duration::toMillis).orElse(cycleTime);
+            tagsByInterval.computeIfAbsent(interval, k -> new 
ArrayList<>()).add(tagName);
+        }
+
+        for (Map.Entry<Long, List<String>> entry : tagsByInterval.entrySet()) {
+            long interval = entry.getKey();
+            List<String> names = entry.getValue();
+            logger.debug("Reporting cyclic tags {} every {}ms", names, 
interval);
+            cyclicTasks.add(EXECUTOR.scheduleAtFixedRate(
+                () -> emitCyclicValues(names), interval, interval, 
TimeUnit.MILLISECONDS));
+        }
+    }
+
+    /** Reports the last known value of the given tags to the registered 
consumers. */
+    private void emitCyclicValues(List<String> names) {
+        try {
+            Map<String, PlcResponseItem<PlcValue>> values = new 
LinkedHashMap<>();
+            for (String tagName : names) {
+                PlcResponseItem<PlcValue> value = lastValues.get(tagName);
+                if (value != null) {
+                    values.put(tagName, value);
+                }
+            }
+            if (values.isEmpty()) {
+                // Nothing received from the server yet - nothing to report.
+                return;
+            }
+            PlcSubscriptionEvent event = new 
DefaultPlcSubscriptionEvent(Instant.now(), values);
+            for (String tagName : values.keySet()) {
+                Consumer<PlcSubscriptionEvent> tagConsumer = 
tagConsumers.get(tagName);
+                if (tagConsumer != null) {
+                    tagConsumer.accept(new 
DefaultPlcSubscriptionEvent(Instant.now(),
+                        Map.of(tagName, values.get(tagName))));
+                }
+            }
+            consumers.forEach(consumer -> consumer.accept(event));
+        } catch (Exception e) {
+            // A failing consumer must not kill the scheduled task for good.
+            logger.error("Error while reporting cyclic values for {}", names, 
e);
+        }
+    }
+
     private void onEventNotification(List<EventFieldList> events) {
         long receiveTs = System.currentTimeMillis();
 
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 9f86217060..335d4f516c 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
@@ -27,6 +27,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.time.Duration;
 import java.util.concurrent.ConcurrentLinkedDeque;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -676,6 +677,44 @@ public class OpcuaPlcDriverTest {
                 }
             }
         }
+        /**
+         * A CYCLIC subscription has to report the value on every interval, 
even when it never
+         * changes - see GH-1102. OPC UA itself only notifies on change, so a 
static value used to
+         * produce exactly one event (the monitored item's initial value) and 
nothing after that.
+         */
+        @Test
+        public void cyclicSubscriptionReportsRepeatedly() throws Exception {
+            PlcConnectionManager connectionManager = new 
DefaultPlcDriverManager().getConnectionManager();
+
+            try (PlcConnection connection = 
connectionManager.getConnection(tcpConnectionAddress)) {
+                ConcurrentLinkedDeque<PlcSubscriptionEvent> events = new 
ConcurrentLinkedDeque<>();
+
+                PlcSubscriptionRequest request = 
connection.subscriptionRequestBuilder()
+                    // A value nobody is writing to - without the fix this 
reports once and stops.
+                    .addCyclicTagAddress("static", 
STRING_IDENTIFIER_READ_WRITE, Duration.ofMillis(500))
+                    .build();
+
+                PlcSubscriptionResponse response = request.execute().get(60, 
TimeUnit.SECONDS);
+                
assertThat(response.getResponseCode("static")).isEqualTo(PlcResponseCode.OK);
+                response.getSubscriptionHandles().forEach(handle -> 
handle.register(events::add));
+
+                // Five cycles of head room for three expected reports.
+                for (int i = 0; i < 50 && events.size() < 3; i++) {
+                    Thread.sleep(100);
+                }
+
+                assertThat(events.size())
+                    .withFailMessage("expected repeated cyclic reports, got %d 
event(s)", events.size())
+                    .isGreaterThanOrEqualTo(3);
+                assertThat(events.getLast().getPlcValue("static")).isNotNull();
+
+                connection.unsubscriptionRequestBuilder()
+                    .addHandles(response.getSubscriptionHandles())
+                    .build()
+                    .execute();
+            }
+        }
+
         /**
          * 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

Reply via email to