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 2c5680ef03 feat: Added base classes for a subscription-eumulation 
layer.
2c5680ef03 is described below

commit 2c5680ef03ee74da9b252bca4f0b7e33c9776381
Author: Christofer Dutz <[email protected]>
AuthorDate: Thu Jul 16 16:45:51 2026 +0200

    feat: Added base classes for a subscription-eumulation layer.
---
 .../model/DefaultPlcSubscriptionHandle.java        |  66 +++
 plc4j/utils/pom.xml                                |   1 +
 plc4j/utils/subscription-emulation/README.md       | 220 ++++++++
 plc4j/utils/subscription-emulation/pom.xml         | 147 +++++
 .../PollingSubscriptionConnectionBase.java         | 593 +++++++++++++++++++++
 .../PollingSubscriptionConnectionBaseTest.java     | 305 +++++++++++
 .../src/test/resources/logback-test.xml            |  16 +
 7 files changed, 1348 insertions(+)

diff --git 
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/DefaultPlcSubscriptionHandle.java
 
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/DefaultPlcSubscriptionHandle.java
new file mode 100644
index 0000000000..4c8fe2b6b1
--- /dev/null
+++ 
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/model/DefaultPlcSubscriptionHandle.java
@@ -0,0 +1,66 @@
+/*
+ * 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
+ *
+ *   http://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.model;
+
+import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent;
+import org.apache.plc4x.java.api.model.PlcConsumerRegistration;
+import org.apache.plc4x.java.api.model.PlcSubscriptionHandle;
+import org.apache.plc4x.java.spi.drivers.functions.PlcSubscriber;
+
+import java.util.Collections;
+import java.util.function.Consumer;
+
+// Warning: do not override equals and hashCode as these should not include 
the plcSubscriber.
+public class DefaultPlcSubscriptionHandle implements PlcSubscriptionHandle {
+
+    private final transient PlcSubscriber plcSubscriber;
+
+    public DefaultPlcSubscriptionHandle(PlcSubscriber plcSubscriber) {
+        this.plcSubscriber = plcSubscriber;
+    }
+
+    @Override
+    public PlcConsumerRegistration register(Consumer<PlcSubscriptionEvent> 
consumer) {
+        return plcSubscriber.registerConsumer(consumer, 
Collections.singletonList(this));
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (!(obj instanceof DefaultPlcSubscriptionHandle)) {
+            return false;
+        }
+        // A handle is unique. Therefore, we use the default implementation 
from Object
+        return (this == obj);
+    }
+
+    @Override
+    public int hashCode() {
+        // A handle is unique. Therefore, we use the default implementation 
from Object
+        return System.identityHashCode(this);
+    }
+
+    @Override
+    public String toString() {
+        return "DefaultPlcSubscriptionHandle{" +
+            "plcSubscriber=" + plcSubscriber +
+            '}';
+    }
+
+}
diff --git a/plc4j/utils/pom.xml b/plc4j/utils/pom.xml
index 5d856b233d..db392fa000 100644
--- a/plc4j/utils/pom.xml
+++ b/plc4j/utils/pom.xml
@@ -37,6 +37,7 @@
 
   <subprojects>
     <subproject>audit-log</subproject>
+    <subproject>subscription-emulation</subproject>
     <!-- Non-default input options -->
     <subproject>pcap-replay</subproject>
     <subproject>pcap-shared</subproject>
diff --git a/plc4j/utils/subscription-emulation/README.md 
b/plc4j/utils/subscription-emulation/README.md
new file mode 100644
index 0000000000..e0ee5c5c83
--- /dev/null
+++ b/plc4j/utils/subscription-emulation/README.md
@@ -0,0 +1,220 @@
+<!--
+  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.
+  -->
+
+# Subscription Emulation
+
+This module provides polling-based subscription emulation for drivers that 
don't natively support subscriptions.
+
+## PollingSubscriptionConnectionBase
+
+The `PollingSubscriptionConnectionBase` class provides polling-based 
subscription emulation for drivers that don't natively support subscriptions 
(such as Modbus, BACnet, etc.).
+
+### Features
+
+- **CYCLIC subscriptions** - Polls tags at specified intervals
+- **CHANGE_OF_STATE subscriptions** - Polls tags and only fires events when 
values change
+- **EVENT subscriptions** - Not supported (throws 
`UnsupportedOperationException`)
+- **Automatic grouping** - Tags with the same subscription type and polling 
interval are grouped into a single batch for efficiency
+- **Consumer registration** - Supports request-level, tag-specific, and 
registered consumers
+- **Thread-safe** - Uses concurrent data structures for thread-safe operation
+
+### Basic Usage
+
+To add polling-based subscription support to your driver:
+
+1. Add this dependency to your driver's `pom.xml`:
+
+```xml
+<dependency>
+    <groupId>org.apache.plc4x</groupId>
+    <artifactId>plc4j-utils-subscription-emulation</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+</dependency>
+```
+
+2. Extend `PollingSubscriptionConnectionBase` instead of `ConnectionBase` and 
implement `PlcReader`:
+
+```java
+import 
org.apache.plc4x.java.utils.subscriptionemulation.PollingSubscriptionConnectionBase;
+
+public class ModbusTcpConnection
+        extends PollingSubscriptionConnectionBase<ModbusTcpConfiguration>
+        implements PlcReader, PlcWriter {
+
+    public ModbusTcpConnection(ModbusTcpConfiguration configuration,
+                               TransportInstance<?> transportInstance) {
+        super(configuration, transportInstance);
+    }
+
+    // Implement PlcReader interface
+    @Override
+    public CompletableFuture<PlcReadResponse> read(PlcReadRequest readRequest) 
{
+        // Your read implementation
+    }
+
+    // Rest of your driver implementation...
+}
+```
+
+That's it! Your driver now supports subscriptions via polling.
+
+### How It Works
+
+When a subscription is created:
+
+1. The base class groups tags by subscription type and polling interval
+2. For each group, it creates an EventPump batch with a TimerTrigger
+3. The batch polls the tags using your driver's `read()` method
+4. For CYCLIC subscriptions, events are fired on every poll
+5. For CHANGE_OF_STATE subscriptions, events are only fired when values change
+
+### Customization
+
+You can override these methods to customize behavior:
+
+#### Custom Default Polling Interval
+
+```java
+@Override
+protected long getDefaultPollingInterval() {
+    return 500; // 500ms instead of default 1000ms
+}
+```
+
+#### Custom Value Comparison
+
+```java
+@Override
+protected boolean valuesEqual(PlcValue v1, PlcValue v2) {
+    // Custom comparison logic
+    // For example, treat small floating point differences as equal
+    if (v1 == v2) return true;
+    if (v1 == null || v2 == null) return false;
+
+    if (v1.isDouble() && v2.isDouble()) {
+        double diff = Math.abs(v1.getDouble() - v2.getDouble());
+        return diff < 0.001; // Treat differences < 0.001 as equal
+    }
+
+    return Objects.equals(v1.getObject(), v2.getObject());
+}
+```
+
+### Usage Examples
+
+#### CYCLIC Subscription
+
+```java
+PlcSubscriptionRequest request = DefaultPlcSubscriptionRequest.builder()
+    .addCyclicTagAddress("temperature", "MAIN.temperature", 
Duration.ofMillis(100))
+    .addCyclicTagAddress("pressure", "MAIN.pressure", Duration.ofMillis(100))
+    .setConsumer(event -> {
+        System.out.println("Temperature: " + event.getInteger("temperature"));
+        System.out.println("Pressure: " + event.getInteger("pressure"));
+    })
+    .build();
+
+CompletableFuture<PlcSubscriptionResponse> future = 
connection.subscribe(request);
+PlcSubscriptionResponse response = future.get();
+
+// Later, to unsubscribe:
+PlcUnsubscriptionRequest unsubRequest = 
DefaultPlcUnsubscriptionRequest.builder()
+    .addHandles(response.getSubscriptionHandle("temperature"))
+    .addHandles(response.getSubscriptionHandle("pressure"))
+    .build();
+
+connection.unsubscribe(unsubRequest).get();
+```
+
+#### CHANGE_OF_STATE Subscription
+
+```java
+PlcSubscriptionRequest request = DefaultPlcSubscriptionRequest.builder()
+    .addChangeOfStateTagAddress("alarm", "MAIN.alarm", Duration.ofMillis(50))
+    .setConsumer(event -> {
+        System.out.println("Alarm state changed: " + 
event.getBoolean("alarm"));
+    })
+    .build();
+
+connection.subscribe(request);
+```
+
+#### Tag-Specific Consumers
+
+```java
+PlcSubscriptionRequest request = DefaultPlcSubscriptionRequest.builder()
+    .addCyclicTagAddress("tag1", "MAIN.tag1", Duration.ofMillis(100))
+    .setTagConsumer("tag1", event -> {
+        System.out.println("Tag1 event: " + event.getInteger("tag1"));
+    })
+    .addCyclicTagAddress("tag2", "MAIN.tag2", Duration.ofMillis(100))
+    .setTagConsumer("tag2", event -> {
+        System.out.println("Tag2 event: " + event.getInteger("tag2"));
+    })
+    .build();
+
+connection.subscribe(request);
+```
+
+#### Consumer Registration
+
+```java
+// Subscribe
+PlcSubscriptionResponse response = connection.subscribe(request).get();
+PlcSubscriptionHandle handle1 = response.getSubscriptionHandle("tag1");
+PlcSubscriptionHandle handle2 = response.getSubscriptionHandle("tag2");
+
+// Register a consumer for specific handles
+PlcConsumerRegistration registration = connection.registerConsumer(
+    event -> {
+        System.out.println("Registered consumer received event");
+    },
+    Arrays.asList(handle1, handle2)
+);
+
+// Later, unregister
+connection.unregisterConsumer(registration);
+```
+
+### Performance Considerations
+
+- **Grouping**: Tags with the same subscription type and polling interval are 
automatically grouped into a single batch, reducing overhead
+- **Concurrency**: All subscription management uses thread-safe concurrent 
data structures
+- **Polling Intervals**: Choose appropriate polling intervals to balance 
responsiveness and network/PLC load
+  - Too frequent polling can overload the PLC or network
+  - Too infrequent polling can miss value changes in CHANGE_OF_STATE 
subscriptions
+
+### Requirements
+
+Your driver must:
+- Extend `PollingSubscriptionConnectionBase` instead of `ConnectionBase`
+- Implement the `PlcReader` interface
+- Add the `plc4j-utils-subscription-emulation` dependency
+
+### Limitations
+
+- **EVENT subscriptions are not supported** - Only CYCLIC and CHANGE_OF_STATE 
are supported
+- **Polling overhead** - All subscriptions are emulated via polling, which may 
not be as efficient as native protocol subscriptions
+- **Value change detection** - CHANGE_OF_STATE subscriptions compare values 
using `Objects.equals()` by default, which may not be suitable for all data 
types (e.g., floating point numbers with small differences)
+
+## Implementation Details
+
+For implementation details, see:
+- `PollingSubscriptionConnectionBase.java:73` - Main implementation
+- `EventPump` - The underlying polling engine (see 
`java/tools/event-pump/README.md`)
diff --git a/plc4j/utils/subscription-emulation/pom.xml 
b/plc4j/utils/subscription-emulation/pom.xml
new file mode 100644
index 0000000000..d45af62e96
--- /dev/null
+++ b/plc4j/utils/subscription-emulation/pom.xml
@@ -0,0 +1,147 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.1.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 
http://maven.apache.org/xsd/maven-4.1.0.xsd";>
+
+  <parent>
+    <groupId>org.apache.plc4x</groupId>
+    <artifactId>plc4j-utils</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>plc4j-utils-subscription-emulation</artifactId>
+
+  <name>PLC4J: Utils: Subscription Emulation</name>
+
+  <properties>
+    
<project.build.outputTimestamp>2026-07-16T15:00:59Z</project.build.outputTimestamp>
+  </properties>
+
+  <build>
+    <plugins>
+      <!-- Override JaCoCo coverage rules for this module -->
+      <!-- Subscription emulation has moderate coverage at 61% -->
+      <plugin>
+        <groupId>org.jacoco</groupId>
+        <artifactId>jacoco-maven-plugin</artifactId>
+        <executions>
+          <execution>
+            <id>check-coverage</id>
+            <phase>verify</phase>
+            <goals>
+              <goal>check</goal>
+            </goals>
+            <configuration>
+              <rules>
+                <rule implementation="org.jacoco.maven.RuleConfiguration">
+                  <element>BUNDLE</element>
+                  <limits>
+                    <!-- Maintain 60% instruction coverage -->
+                    <limit implementation="org.jacoco.report.check.Limit">
+                      <counter>INSTRUCTION</counter>
+                      <value>COVEREDRATIO</value>
+                      <minimum>0.60</minimum>
+                    </limit>
+                    <!-- Allow up to 3 classes without coverage -->
+                    <limit implementation="org.jacoco.report.check.Limit">
+                      <counter>CLASS</counter>
+                      <value>MISSEDCOUNT</value>
+                      <maximum>3</maximum>
+                    </limit>
+                  </limits>
+                </rule>
+              </rules>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+
+  <dependencies>
+    <!-- PLC4J API -->
+    <dependency>
+      <groupId>org.apache.plc4x</groupId>
+      <artifactId>plc4j-api</artifactId>
+      <version>1.0.0-SNAPSHOT</version>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.plc4x</groupId>
+      <artifactId>plc4j-spi-config</artifactId>
+      <version>1.0.0-SNAPSHOT</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.plc4x</groupId>
+      <artifactId>plc4j-spi-drivers</artifactId>
+      <version>1.0.0-SNAPSHOT</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.plc4x</groupId>
+      <artifactId>plc4j-transports-api</artifactId>
+      <version>1.0.0-SNAPSHOT</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.plc4x</groupId>
+      <artifactId>plc4j-utils-audit-log-api</artifactId>
+      <version>1.0.0-SNAPSHOT</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.plc4x</groupId>
+      <artifactId>plc4j-tools-event-pump</artifactId>
+      <version>1.0.0-SNAPSHOT</version>
+    </dependency>
+
+    <!-- Logging -->
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+    </dependency>
+
+    <!-- Test Dependencies -->
+    <dependency>
+      <groupId>org.apache.plc4x</groupId>
+      <artifactId>plc4j-spi-values</artifactId>
+      <version>1.0.0-SNAPSHOT</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.plc4x</groupId>
+      <artifactId>plc4j-utils-test-utils</artifactId>
+      <version>1.0.0-SNAPSHOT</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>ch.qos.logback</groupId>
+      <artifactId>logback-classic</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-api</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+</project>
diff --git 
a/plc4j/utils/subscription-emulation/src/main/java/org/apache/plc4x/java/utils/subscriptionemulation/PollingSubscriptionConnectionBase.java
 
b/plc4j/utils/subscription-emulation/src/main/java/org/apache/plc4x/java/utils/subscriptionemulation/PollingSubscriptionConnectionBase.java
new file mode 100644
index 0000000000..0a0f460ee6
--- /dev/null
+++ 
b/plc4j/utils/subscription-emulation/src/main/java/org/apache/plc4x/java/utils/subscriptionemulation/PollingSubscriptionConnectionBase.java
@@ -0,0 +1,593 @@
+/*
+ * 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
+ *
+ *   http://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.utils.subscriptionemulation;
+
+import org.apache.plc4x.java.api.PlcConnection;
+import org.apache.plc4x.java.api.PlcConnectionManager;
+import org.apache.plc4x.java.api.authentication.PlcAuthentication;
+import org.apache.plc4x.java.api.exceptions.PlcConnectionException;
+import org.apache.plc4x.java.api.metadata.PlcConnectionMetadata;
+import org.apache.plc4x.java.api.messages.PlcReadResponse;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionRequest;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionResponse;
+import org.apache.plc4x.java.api.messages.PlcUnsubscriptionRequest;
+import org.apache.plc4x.java.api.messages.PlcUnsubscriptionResponse;
+import org.apache.plc4x.java.api.model.PlcTag;
+import org.apache.plc4x.java.api.model.PlcConsumerRegistration;
+import org.apache.plc4x.java.api.model.PlcSubscriptionHandle;
+import org.apache.plc4x.java.api.model.PlcSubscriptionTag;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.api.types.PlcSubscriptionType;
+import org.apache.plc4x.java.api.value.PlcValue;
+import org.apache.plc4x.java.spi.config.Configuration;
+import org.apache.plc4x.java.spi.drivers.ConnectionBase;
+import org.apache.plc4x.java.spi.drivers.functions.PlcSubscriber;
+import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcSubscriptionEvent;
+import 
org.apache.plc4x.java.spi.drivers.messages.DefaultPlcSubscriptionResponse;
+import 
org.apache.plc4x.java.spi.drivers.messages.DefaultPlcUnsubscriptionResponse;
+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.model.DefaultPlcSubscriptionHandle;
+import org.apache.plc4x.java.spi.transports.api.TransportInstance;
+import org.apache.plc4x.java.tools.eventpump.EventPump;
+import org.apache.plc4x.java.tools.eventpump.TagBatch;
+import org.apache.plc4x.java.tools.eventpump.triggers.TimerTrigger;
+import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Consumer;
+
+/**
+ * Extended base class for PLC connections that emulates subscriptions using 
polling.
+ * <p>
+ * This class is designed for drivers that don't natively support 
subscriptions (like Modbus).
+ * It uses the EventPump tool to poll tags at regular intervals and emulates:
+ * <ul>
+ *   <li>CYCLIC subscriptions - polls at the specified interval</li>
+ *   <li>CHANGE_OF_STATE subscriptions - polls and detects value changes</li>
+ *   <li>EVENT subscriptions - not supported (throws 
UnsupportedOperationException)</li>
+ * </ul>
+ * <p>
+ * To use this class, simply extend it instead of ConnectionBase and implement 
PlcReader:
+ * <pre>
+ * public class MyConnection extends 
PollingSubscriptionConnectionBase&lt;MyConfig&gt; implements PlcReader {
+ *     // Your driver implementation
+ * }
+ * </pre>
+ *
+ * @param <C> the configuration type
+ */
+public abstract class PollingSubscriptionConnectionBase<C extends 
Configuration> extends ConnectionBase<C> {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(PollingSubscriptionConnectionBase.class);
+
+    private final EventPump eventPump = new EventPump();
+    private final AtomicLong subscriptionIdGenerator = new AtomicLong(1);
+    private final Map<Long, SubscriptionInfo> subscriptions = new 
ConcurrentHashMap<>();
+    private final Map<PlcConsumerRegistration, ConsumerRegistrationInfo> 
consumerRegistrations = new ConcurrentHashMap<>();
+    private final PlcConnectionManager singleConnectionManager;
+
+    public PollingSubscriptionConnectionBase(C configuration, 
TransportInstance<?> transportInstance, AuditLog auditLog, String artifactId) {
+        super(configuration, transportInstance, auditLog);
+        // Create a connection manager that always returns this connection
+        this.singleConnectionManager = new SingleConnectionManager(this);
+    }
+
+    @Override
+    protected CompletableFuture<PlcSubscriptionResponse> 
onSubscribe(PlcSubscriptionRequest subscriptionRequest) {
+        LinkedHashMap<String, PlcResponseItem<PlcSubscriptionHandle>> 
responseItems = new LinkedHashMap<>();
+
+        try {
+            // Group tags by subscription type and polling interval
+            Map<SubscriptionKey, Map<String, PlcSubscriptionTag>> tagGroups = 
groupTagsBySubscriptionType(subscriptionRequest);
+
+            // Create a batch for each group
+            for (Map.Entry<SubscriptionKey, Map<String, PlcSubscriptionTag>> 
entry : tagGroups.entrySet()) {
+                SubscriptionKey key = entry.getKey();
+                Map<String, PlcSubscriptionTag> groupTags = entry.getValue();
+
+                Long subscriptionId = 
subscriptionIdGenerator.getAndIncrement();
+                String batchId = "subscription-" + subscriptionId;
+
+                // Create tag batch with appropriate trigger
+                TimerTrigger trigger = createTrigger(key.pollingInterval);
+
+                // Convert tags into an address map
+                Map<String, String> tagAddresses = new HashMap<>();
+                groupTags.forEach((name, tag) -> tagAddresses.put(name, 
tag.getAddressString()));
+
+                // Create subscription info for value change detection
+                SubscriptionInfo subscriptionInfo = new SubscriptionInfo(
+                    subscriptionId,
+                    batchId,
+                    key.subscriptionType,
+                    groupTags,
+                    subscriptionRequest.getConsumer(),
+                    new ConcurrentHashMap<>() // tag-specific consumers
+                );
+
+                // Add tag-specific consumers
+                groupTags.keySet().forEach(tagName -> {
+                    Consumer<PlcSubscriptionEvent> tagConsumer = 
subscriptionRequest.getTagConsumer(tagName);
+                    if (tagConsumer != null) {
+                        subscriptionInfo.tagConsumers.put(tagName, 
tagConsumer);
+                    }
+                });
+
+                TagBatch batch = TagBatch.builder()
+                    .withBatchId(batchId)
+                    .withConnectionManager(singleConnectionManager)
+                    .withConnectionString("internal://subscription")
+                    .addTagAddresses(tagAddresses)
+                    .withTrigger(trigger)
+                    .withListener((b, response) -> 
handleSubscriptionEvent(subscriptionInfo, response))
+                    .build();
+
+                eventPump.addBatch(batch);
+                eventPump.startBatch(batchId);
+
+                subscriptions.put(subscriptionId, subscriptionInfo);
+
+                // Create subscription handles for response
+                for (String tagName : groupTags.keySet()) {
+                    PlcSubscriptionHandle handle = new 
PollingSubscriptionHandle(this, subscriptionId, tagName);
+                    responseItems.put(tagName, new 
DefaultPlcResponseItem<>(PlcResponseCode.OK, handle));
+                }
+
+                LOGGER.debug("Created {} subscription batch '{}' with {} tags, 
polling interval: {}ms",
+                    key.subscriptionType, batchId, groupTags.size(), 
key.pollingInterval);
+            }
+
+            return CompletableFuture.completedFuture(
+                new DefaultPlcSubscriptionResponse(subscriptionRequest, 
responseItems));
+
+        } catch (Exception e) {
+            LOGGER.error("Error creating subscription", e);
+            return CompletableFuture.failedFuture(e);
+        }
+    }
+
+    @Override
+    protected CompletableFuture<PlcUnsubscriptionResponse> 
onUnsubscribe(PlcUnsubscriptionRequest unsubscriptionRequest) {
+        Set<Long> subscriptionsToRemove = new HashSet<>();
+
+        for (PlcSubscriptionHandle handle : 
unsubscriptionRequest.getSubscriptionHandles()) {
+            if (handle instanceof PollingSubscriptionHandle pollingHandle) {
+                Long subscriptionId = pollingHandle.getSubscriptionId();
+                SubscriptionInfo info = subscriptions.get(subscriptionId);
+
+                if (info != null) {
+                    subscriptionsToRemove.add(subscriptionId);
+                } else {
+                    LOGGER.warn("Subscription not found for handle: {}", 
handle);
+                }
+            } else {
+                LOGGER.warn("Invalid handle type: {}", handle.getClass());
+            }
+        }
+
+        // Stop and remove all identified subscriptions
+        for (Long subscriptionId : subscriptionsToRemove) {
+            SubscriptionInfo info = subscriptions.remove(subscriptionId);
+            if (info != null) {
+                eventPump.stopBatch(info.batchId);
+                eventPump.removeBatch(info.batchId);
+                LOGGER.debug("Unsubscribed from batch '{}'", info.batchId);
+            }
+        }
+
+        return CompletableFuture.completedFuture(
+            new DefaultPlcUnsubscriptionResponse(unsubscriptionRequest));
+    }
+
+    @Override
+    protected PlcConsumerRegistration 
onRegisterConsumer(Consumer<PlcSubscriptionEvent> consumer, 
Collection<PlcSubscriptionHandle> handles) {
+        List<PlcSubscriptionHandle> handleList = new ArrayList<>(handles);
+        int consumerId = (int) subscriptionIdGenerator.getAndIncrement();
+        PlcConsumerRegistration registration = new PlcConsumerRegistration() {
+            @Override
+            public void unregister() {
+                onUnregisterConsumer(this);
+            }
+
+            @Override
+            public List<PlcSubscriptionHandle> getSubscriptionHandles() {
+                return Collections.unmodifiableList(handleList);
+            }
+
+            @Override
+            public Integer getConsumerId() {
+                return consumerId;
+            }
+        };
+        ConsumerRegistrationInfo info = new ConsumerRegistrationInfo(consumer, 
new HashSet<>(handleList));
+        consumerRegistrations.put(registration, info);
+
+        LOGGER.debug("Registered consumer for {} subscription handles", 
handles.size());
+        return registration;
+    }
+
+    @Override
+    protected void onUnregisterConsumer(PlcConsumerRegistration registration) {
+        ConsumerRegistrationInfo removed = 
consumerRegistrations.remove(registration);
+        if (removed != null) {
+            LOGGER.debug("Unregistered consumer");
+        }
+    }
+
+    @Override
+    public void close() throws Exception {
+        // Stop all subscriptions
+        eventPump.close();
+        subscriptions.clear();
+        consumerRegistrations.clear();
+
+        super.close();
+        LOGGER.info("Closed polling subscription connection, all subscriptions 
stopped");
+    }
+
+    /**
+     * Handles a subscription event from the event pump.
+     * For CHANGE_OF_STATE subscriptions, only fires if values have changed.
+     */
+    private void handleSubscriptionEvent(SubscriptionInfo info, 
PlcReadResponse response) {
+        try {
+            boolean shouldFire = false;
+            LinkedHashMap<String, PlcResponseItem<PlcValue>> eventValues = new 
LinkedHashMap<>();
+
+            for (String tagName : response.getTagNames()) {
+                PlcResponseCode code = response.getResponseCode(tagName);
+                if (code == PlcResponseCode.OK) {
+                    PlcValue currentValue = response.getPlcValue(tagName);
+                    PlcValue previousValue = info.previousValues.get(tagName);
+
+                    if (info.subscriptionType == PlcSubscriptionType.CYCLIC) {
+                        // For cyclic subscriptions, always fire
+                        shouldFire = true;
+                        eventValues.put(tagName, new 
DefaultPlcResponseItem<>(code, currentValue));
+                        info.previousValues.put(tagName, currentValue);
+
+                    } else if (info.subscriptionType == 
PlcSubscriptionType.CHANGE_OF_STATE) {
+                        // For change-of-state, only fire if value changed
+                        if (previousValue == null || 
!valuesEqual(previousValue, currentValue)) {
+                            shouldFire = true;
+                            eventValues.put(tagName, new 
DefaultPlcResponseItem<>(code, currentValue));
+                            info.previousValues.put(tagName, currentValue);
+                            LOGGER.trace("Value changed for tag '{}': {} -> 
{}", tagName, previousValue, currentValue);
+                        }
+                    }
+                } else {
+                    // Include error responses
+                    eventValues.put(tagName, new 
DefaultPlcResponseItem<>(code, null));
+                }
+            }
+
+            if (shouldFire && !eventValues.isEmpty()) {
+                PlcSubscriptionEvent event = new DefaultPlcSubscriptionEvent(
+                    Instant.now(),
+                    eventValues
+                );
+
+                // Fire to request-level consumer
+                if (info.requestConsumer != null) {
+                    info.requestConsumer.accept(event);
+                }
+
+                // Fire to tag-specific consumers
+                for (String tagName : eventValues.keySet()) {
+                    Consumer<PlcSubscriptionEvent> tagConsumer = 
info.tagConsumers.get(tagName);
+                    if (tagConsumer != null) {
+                        tagConsumer.accept(event);
+                    }
+                }
+
+                // Fire to registered consumers
+                for (ConsumerRegistrationInfo regInfo : 
consumerRegistrations.values()) {
+                    // Check if this registration includes any of our 
subscription handles
+                    for (PlcSubscriptionHandle handle : regInfo.handles) {
+                        if (handle instanceof PollingSubscriptionHandle 
pollingHandle) {
+                            if 
(pollingHandle.getSubscriptionId().equals(info.subscriptionId)) {
+                                regInfo.consumer.accept(event);
+                                break;
+                            }
+                        }
+                    }
+                }
+            }
+
+        } catch (Exception e) {
+            LOGGER.error("Error handling subscription event for batch '{}'", 
info.batchId, e);
+        }
+    }
+
+    /**
+     * Groups subscription tags by type and polling interval.
+     */
+    private Map<SubscriptionKey, Map<String, PlcSubscriptionTag>> 
groupTagsBySubscriptionType(PlcSubscriptionRequest request) {
+        Map<SubscriptionKey, Map<String, PlcSubscriptionTag>> groups = new 
LinkedHashMap<>();
+
+        for (String tagName : request.getTagNames()) {
+            PlcSubscriptionTag tag = request.getTag(tagName);
+            PlcSubscriptionType type = tag.getPlcSubscriptionType();
+
+            // EVENT subscriptions are not supported
+            if (type == PlcSubscriptionType.EVENT) {
+                throw new UnsupportedOperationException(
+                    "EVENT subscriptions are not supported with polling-based 
subscriptions. " +
+                    "Use CYCLIC or CHANGE_OF_STATE instead.");
+            }
+
+            long pollingInterval;
+            if (type == PlcSubscriptionType.CYCLIC && 
tag.getDuration().isPresent()) {
+                pollingInterval = tag.getDuration().get().toMillis();
+            } else {
+                // Default polling interval for CHANGE_OF_STATE or if not 
specified
+                pollingInterval = getDefaultPollingInterval();
+            }
+
+            SubscriptionKey key = new SubscriptionKey(type, pollingInterval);
+            groups.computeIfAbsent(key, k -> new 
LinkedHashMap<>()).put(tagName, tag);
+        }
+
+        return groups;
+    }
+
+    /**
+     * Creates a trigger based on the polling interval.
+     */
+    private TimerTrigger createTrigger(long pollingIntervalMs) {
+        return new TimerTrigger(pollingIntervalMs, TimeUnit.MILLISECONDS);
+    }
+
+    /**
+     * Compares two PlcValues for equality.
+     * Override this method if you need custom value comparison logic.
+     */
+    protected boolean valuesEqual(PlcValue v1, PlcValue v2) {
+        if (v1 == v2) return true;
+        if (v1 == null || v2 == null) return false;
+
+        // Compare as objects
+        return Objects.equals(v1.getObject(), v2.getObject());
+    }
+
+    /**
+     * Get the default polling interval for CHANGE_OF_STATE subscriptions.
+     * Override this method to provide a driver-specific default.
+     *
+     * @return Default polling interval in milliseconds (default: 1000ms)
+     */
+    protected long getDefaultPollingInterval() {
+        return 1000; // 1 second default
+    }
+
+    /**
+     * Key for grouping subscriptions by type and interval.
+     */
+    private static class SubscriptionKey {
+        final PlcSubscriptionType subscriptionType;
+        final long pollingInterval;
+
+        SubscriptionKey(PlcSubscriptionType subscriptionType, long 
pollingInterval) {
+            this.subscriptionType = subscriptionType;
+            this.pollingInterval = pollingInterval;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (!(o instanceof SubscriptionKey that)) return false;
+            return pollingInterval == that.pollingInterval && subscriptionType 
== that.subscriptionType;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(subscriptionType, pollingInterval);
+        }
+    }
+
+    /**
+     * Information about an active subscription.
+     */
+    private static class SubscriptionInfo {
+        final Long subscriptionId;
+        final String batchId;
+        final PlcSubscriptionType subscriptionType;
+        final Map<String, PlcSubscriptionTag> tags;
+        final Consumer<PlcSubscriptionEvent> requestConsumer;
+        final Map<String, Consumer<PlcSubscriptionEvent>> tagConsumers;
+        final Map<String, PlcValue> previousValues = new ConcurrentHashMap<>();
+
+        SubscriptionInfo(Long subscriptionId,
+                        String batchId,
+                        PlcSubscriptionType subscriptionType,
+                        Map<String, PlcSubscriptionTag> tags,
+                        Consumer<PlcSubscriptionEvent> requestConsumer,
+                        Map<String, Consumer<PlcSubscriptionEvent>> 
tagConsumers) {
+            this.subscriptionId = subscriptionId;
+            this.batchId = batchId;
+            this.subscriptionType = subscriptionType;
+            this.tags = tags;
+            this.requestConsumer = requestConsumer;
+            this.tagConsumers = tagConsumers;
+        }
+    }
+
+    /**
+     * Information about a consumer registration.
+     */
+    private static class ConsumerRegistrationInfo {
+        final Consumer<PlcSubscriptionEvent> consumer;
+        final Set<PlcSubscriptionHandle> handles;
+
+        ConsumerRegistrationInfo(Consumer<PlcSubscriptionEvent> consumer, 
Set<PlcSubscriptionHandle> handles) {
+            this.consumer = consumer;
+            this.handles = handles;
+        }
+    }
+
+    /**
+     * Custom subscription handle for polling-based subscriptions that stores 
the subscription ID and tag name.
+     */
+    private static class PollingSubscriptionHandle extends 
DefaultPlcSubscriptionHandle {
+        private final Long subscriptionId;
+        private final String tagName;
+
+        PollingSubscriptionHandle(PlcSubscriber subscriber, Long 
subscriptionId, String tagName) {
+            super(subscriber);
+            this.subscriptionId = subscriptionId;
+            this.tagName = tagName;
+        }
+
+        public Long getSubscriptionId() {
+            return subscriptionId;
+        }
+
+        public String getTagName() {
+            return tagName;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (!(obj instanceof PollingSubscriptionHandle other)) {
+                return false;
+            }
+            return Objects.equals(subscriptionId, other.subscriptionId) &&
+                   Objects.equals(tagName, other.tagName);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(subscriptionId, tagName);
+        }
+
+        @Override
+        public String toString() {
+            return "PollingSubscriptionHandle{" +
+                "subscriptionId=" + subscriptionId +
+                ", tagName='" + tagName + '\'' +
+                '}';
+        }
+    }
+
+    /**
+     * Simple connection manager that always returns the same connection 
wrapped in a non-closing proxy.
+     * This is used internally to adapt TagBatch's new API to work with a 
single connection.
+     * The wrapper prevents TagBatch from closing the connection when using 
try-with-resources.
+     */
+    private static class SingleConnectionManager implements 
PlcConnectionManager {
+        private final PlcConnection wrappedConnection;
+
+        SingleConnectionManager(PlcConnection connection) {
+            // Wrap the connection to prevent it from being closed by TagBatch
+            this.wrappedConnection = new 
NonClosingConnectionWrapper(connection);
+        }
+
+        @Override
+        public PlcConnection getConnection(String connectionString) throws 
PlcConnectionException {
+            return wrappedConnection;
+        }
+
+        @Override
+        public PlcConnection getConnection(String connectionString, 
PlcAuthentication authentication) throws PlcConnectionException {
+            return wrappedConnection;
+        }
+    }
+
+    /**
+     * Wrapper that delegates all calls to the underlying connection except 
close().
+     * This prevents TagBatch from closing the connection when used in 
try-with-resources.
+     */
+    private static class NonClosingConnectionWrapper implements PlcConnection {
+        private final PlcConnection delegate;
+
+        NonClosingConnectionWrapper(PlcConnection delegate) {
+            this.delegate = delegate;
+        }
+
+        @Override
+        public void connect() {
+            // Already connected
+        }
+
+        @Override
+        public boolean isConnected() {
+            return delegate.isConnected();
+        }
+
+        @Override
+        public void close() {
+            // Intentionally do nothing - we don't want TagBatch to close the 
underlying connection
+        }
+
+        @Override
+        public Optional<PlcTag> parseTagAddress(String tagAddress) {
+            return delegate.parseTagAddress(tagAddress);
+        }
+
+        @Override
+        public Optional<PlcValue> parseTagValue(PlcTag tag, Object... values) {
+            return delegate.parseTagValue(tag, values);
+        }
+
+        @Override
+        public CompletableFuture<? extends 
org.apache.plc4x.java.api.messages.PlcPingResponse> ping() {
+            return delegate.ping();
+        }
+
+        @Override
+        public org.apache.plc4x.java.api.messages.PlcReadRequest.Builder 
readRequestBuilder() {
+            return delegate.readRequestBuilder();
+        }
+
+        @Override
+        public org.apache.plc4x.java.api.messages.PlcWriteRequest.Builder 
writeRequestBuilder() {
+            return delegate.writeRequestBuilder();
+        }
+
+        @Override
+        public 
org.apache.plc4x.java.api.messages.PlcSubscriptionRequest.Builder 
subscriptionRequestBuilder() {
+            return delegate.subscriptionRequestBuilder();
+        }
+
+        @Override
+        public 
org.apache.plc4x.java.api.messages.PlcUnsubscriptionRequest.Builder 
unsubscriptionRequestBuilder() {
+            return delegate.unsubscriptionRequestBuilder();
+        }
+
+        @Override
+        public org.apache.plc4x.java.api.messages.PlcBrowseRequest.Builder 
browseRequestBuilder() {
+            return delegate.browseRequestBuilder();
+        }
+
+        @Override
+        public PlcConnectionMetadata getMetadata() {
+            return delegate.getMetadata();
+        }
+    }
+}
diff --git 
a/plc4j/utils/subscription-emulation/src/test/java/org/apache/plc4x/java/utils/subscriptionemulation/PollingSubscriptionConnectionBaseTest.java
 
b/plc4j/utils/subscription-emulation/src/test/java/org/apache/plc4x/java/utils/subscriptionemulation/PollingSubscriptionConnectionBaseTest.java
new file mode 100644
index 0000000000..78bfd12e71
--- /dev/null
+++ 
b/plc4j/utils/subscription-emulation/src/test/java/org/apache/plc4x/java/utils/subscriptionemulation/PollingSubscriptionConnectionBaseTest.java
@@ -0,0 +1,305 @@
+/*
+ * 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
+ *
+ *   http://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.utils.subscriptionemulation;
+
+import org.apache.plc4x.java.api.messages.*;
+import org.apache.plc4x.java.api.model.PlcTag;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.api.value.PlcValue;
+import org.apache.plc4x.java.spi.config.Configuration;
+import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcReadResponse;
+import 
org.apache.plc4x.java.spi.drivers.messages.DefaultPlcSubscriptionRequest;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem;
+import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler;
+import org.apache.plc4x.java.spi.transports.api.TransportInstance;
+import org.apache.plc4x.java.spi.values.PlcValueHandler;
+import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests for PollingSubscriptionConnectionBase.
+ */
+class PollingSubscriptionConnectionBaseTest {
+
+    private TestConnection connection;
+    private PlcTagHandler tagHandler;
+    private AtomicInteger mockValueCounter;
+
+    @BeforeEach
+    void setUp() {
+        mockValueCounter = new AtomicInteger(100);
+        tagHandler = mock(PlcTagHandler.class);
+
+        // Setup tag handler to return mock tags
+        PlcTag mockTag = mock(PlcTag.class);
+        when(mockTag.getAddressString()).thenReturn("MAIN.tag1");
+        when(tagHandler.parseTag(anyString())).thenReturn(mockTag);
+
+        connection = new TestConnection(mockValueCounter, tagHandler);
+    }
+
+    @Test
+    void testCyclicSubscriptionFiresRegularly() throws Exception {
+        // Arrange
+        CountDownLatch latch = new CountDownLatch(3);
+        List<Integer> receivedValues = Collections.synchronizedList(new 
ArrayList<>());
+
+        PlcSubscriptionRequest request = new 
DefaultPlcSubscriptionRequest.Builder(connection, tagHandler)
+            .addCyclicTagAddress("tag1", "MAIN.tag1", Duration.ofMillis(100))
+            .setConsumer(event -> {
+                PlcValue value = event.getPlcValue("tag1");
+                receivedValues.add(value.getInteger());
+                latch.countDown();
+            })
+            .build();
+
+        // Act
+        CompletableFuture<PlcSubscriptionResponse> subscribeFuture = 
connection.subscribe(request);
+        PlcSubscriptionResponse response = subscribeFuture.get(1, 
TimeUnit.SECONDS);
+
+        // Assert subscription was successful
+        assertEquals(PlcResponseCode.OK, response.getResponseCode("tag1"));
+        assertNotNull(response.getSubscriptionHandle("tag1"));
+
+        // Wait for multiple events (generous timeout for CI/heavy classpath 
environments)
+        boolean completed = latch.await(2, TimeUnit.SECONDS);
+        assertTrue(completed, "Should have received at least 3 events");
+        assertTrue(receivedValues.size() >= 3, "Should have received at least 
3 values");
+
+        // Cleanup
+        connection.close();
+    }
+
+    @Test
+    void testChangeOfStateSubscriptionOnlyFiresOnChange() throws Exception {
+        // Arrange
+        CountDownLatch latch = new CountDownLatch(2);
+        List<Integer> receivedValues = Collections.synchronizedList(new 
ArrayList<>());
+
+        PlcSubscriptionRequest request = new 
DefaultPlcSubscriptionRequest.Builder(connection, tagHandler)
+            .addChangeOfStateTagAddress("tag1", "MAIN.tag1")
+            .setConsumer(event -> {
+                PlcValue value = event.getPlcValue("tag1");
+                receivedValues.add(value.getInteger());
+                latch.countDown();
+            })
+            .build();
+
+        // Act
+        CompletableFuture<PlcSubscriptionResponse> subscribeFuture = 
connection.subscribe(request);
+        PlcSubscriptionResponse response = subscribeFuture.get(1, 
TimeUnit.SECONDS);
+
+        // Assert subscription was successful
+        assertEquals(PlcResponseCode.OK, response.getResponseCode("tag1"));
+
+        // Simulate value changes
+        Thread.sleep(200); // Wait for the first poll (value=100)
+        mockValueCounter.set(200); // Change value
+        Thread.sleep(300); // Wait for polls
+
+        boolean completed = latch.await(2, TimeUnit.SECONDS);
+        assertTrue(completed, "Should have received at least 2 change events");
+
+        // Should have received at least 2 different values
+        assertTrue(receivedValues.size() >= 2, "Should have at least 2 
values");
+
+        // Cleanup
+        connection.close();
+    }
+
+    @Test
+    void testEventSubscriptionThrowsException() {
+        // Arrange
+        PlcSubscriptionRequest request = new 
DefaultPlcSubscriptionRequest.Builder(connection, tagHandler)
+            .addEventTagAddress("tag1", "MAIN.tag1")
+            .build();
+
+        // Act & Assert
+        assertThrows(Exception.class, () -> {
+            CompletableFuture<PlcSubscriptionResponse> subscribeFuture = 
connection.subscribe(request);
+            subscribeFuture.get(1, TimeUnit.SECONDS);
+        });
+
+        // Cleanup
+        assertDoesNotThrow(() -> connection.close());
+    }
+
+    @Test
+    void testConnectionWithoutReaderDoesNotFireEvents() throws Exception {
+        // Arrange - Create a connection that does NOT override onRead()
+        // Subscribe will succeed, but polling will fail because default 
onRead() returns "not supported"
+        TestConnectionWithoutReader connection = new 
TestConnectionWithoutReader(tagHandler);
+
+        AtomicInteger eventCount = new AtomicInteger(0);
+
+        PlcSubscriptionRequest request = new 
DefaultPlcSubscriptionRequest.Builder(connection, tagHandler)
+            .addCyclicTagAddress("tag1", "MAIN.tag1", Duration.ofMillis(50))
+            .setConsumer(event -> eventCount.incrementAndGet())
+            .build();
+
+        // Act - subscribe should succeed
+        CompletableFuture<PlcSubscriptionResponse> subscribeFuture = 
connection.subscribe(request);
+        PlcSubscriptionResponse response = subscribeFuture.get(1, 
TimeUnit.SECONDS);
+        assertEquals(PlcResponseCode.OK, response.getResponseCode("tag1"));
+
+        // Wait a bit — events should NOT fire because onRead() returns "not 
supported"
+        Thread.sleep(200);
+        assertEquals(0, eventCount.get(), "Should not receive events when 
onRead is not overridden");
+
+        // Cleanup
+        assertDoesNotThrow(() -> connection.close());
+    }
+
+    @Test
+    void testCustomValueComparison() throws Exception {
+        // Create a connection with custom value comparison that ignores 
changes
+        TestConnectionWithCustomComparison customConnection = new 
TestConnectionWithCustomComparison(mockValueCounter, tagHandler);
+
+        CountDownLatch latch = new CountDownLatch(1);
+        AtomicInteger eventCount = new AtomicInteger(0);
+
+        PlcSubscriptionRequest request = new 
DefaultPlcSubscriptionRequest.Builder(customConnection, tagHandler)
+            .addChangeOfStateTagAddress("tag1", "MAIN.tag1")
+            .setConsumer(event -> {
+                eventCount.incrementAndGet();
+                latch.countDown();
+            })
+            .build();
+
+        // Subscribe
+        CompletableFuture<PlcSubscriptionResponse> subscribeFuture = 
customConnection.subscribe(request);
+        subscribeFuture.get(1, TimeUnit.SECONDS);
+
+        // Wait for first event
+        latch.await(2, TimeUnit.SECONDS);
+        assertEquals(1, eventCount.get(), "Should have received initial 
event");
+
+        // Change value but custom comparator always returns true (equal)
+        mockValueCounter.set(999);
+        Thread.sleep(150);
+
+        // Should still only have 1 event because custom comparator says 
values are equal
+        assertTrue(eventCount.get() <= 2, "Should not receive many events with 
custom always-equal comparator");
+
+        // Cleanup
+        customConnection.close();
+    }
+
+    // Test stub implementation
+    static class TestConnection extends 
PollingSubscriptionConnectionBase<TestConfiguration> {
+        private final AtomicInteger valueSupplier;
+        private final PlcTagHandler tagHandler;
+
+        public TestConnection(AtomicInteger valueSupplier, PlcTagHandler 
tagHandler) {
+            super(new TestConfiguration(), mock(TransportInstance.class), 
mock(AuditLog.class), "test");
+            this.valueSupplier = valueSupplier;
+            this.tagHandler = tagHandler;
+        }
+
+        @Override
+        protected CompletableFuture<PlcReadResponse> onRead(PlcReadRequest 
readRequest) {
+            LinkedHashMap<String, DefaultPlcResponseItem<PlcValue>> 
responseItems = new LinkedHashMap<>();
+
+            for (String tagName : readRequest.getTagNames()) {
+                PlcValue value = mock(PlcValue.class);
+                when(value.getInteger()).thenReturn(valueSupplier.get());
+                when(value.getObject()).thenReturn(valueSupplier.get());
+
+                responseItems.put(tagName, new 
DefaultPlcResponseItem<>(PlcResponseCode.OK, value));
+            }
+
+            PlcReadResponse response = new DefaultPlcReadResponse(readRequest, 
(Map) responseItems);
+            return CompletableFuture.completedFuture(response);
+        }
+
+        @Override
+        protected PlcTagHandler getTagHandler() {
+            return tagHandler;
+        }
+
+        @Override
+        protected PlcValueHandler getValueHandler() {
+            return null;
+        }
+
+        @Override
+        protected int getMaxConcurrentRequests() {
+            return 10;
+        }
+
+        @Override
+        protected long getDefaultPollingInterval() {
+            return 50; // Fast polling for tests
+        }
+    }
+
+    // Test connection that does NOT implement PlcReader
+    static class TestConnectionWithoutReader extends 
PollingSubscriptionConnectionBase<TestConfiguration> {
+        private final PlcTagHandler tagHandler;
+
+        public TestConnectionWithoutReader(PlcTagHandler tagHandler) {
+            super(new TestConfiguration(), mock(TransportInstance.class), 
mock(AuditLog.class), "test");
+            this.tagHandler = tagHandler;
+        }
+
+        @Override
+        protected PlcTagHandler getTagHandler() {
+            return tagHandler;
+        }
+
+        @Override
+        protected PlcValueHandler getValueHandler() {
+            return null;
+        }
+
+        @Override
+        protected int getMaxConcurrentRequests() {
+            return 10;
+        }
+    }
+
+    // Test connection with custom value comparison
+    static class TestConnectionWithCustomComparison extends TestConnection {
+        public TestConnectionWithCustomComparison(AtomicInteger valueSupplier, 
PlcTagHandler tagHandler) {
+            super(valueSupplier, tagHandler);
+        }
+
+        @Override
+        protected boolean valuesEqual(PlcValue v1, PlcValue v2) {
+            // Always return true (values are always "equal")
+            return true;
+        }
+    }
+
+    // Test configuration
+    static class TestConfiguration implements Configuration {
+    }
+}
diff --git 
a/plc4j/utils/subscription-emulation/src/test/resources/logback-test.xml 
b/plc4j/utils/subscription-emulation/src/test/resources/logback-test.xml
new file mode 100644
index 0000000000..ae7fbb5130
--- /dev/null
+++ b/plc4j/utils/subscription-emulation/src/test/resources/logback-test.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - 
%msg%n</pattern>
+        </encoder>
+    </appender>
+
+    <!-- Set default log level to WARN for cleaner test output -->
+    <root level="WARN">
+        <appender-ref ref="STDOUT"/>
+    </root>
+
+    <!-- You can enable DEBUG for specific loggers if needed during debugging 
-->
+    <!-- <logger name="org.apache.plc4x.java.utils.subscriptionemulation" 
level="DEBUG"/> -->
+</configuration>

Reply via email to