diff --git a/.github/workflows/docker-milestone-release.yml b/.github/workflows/docker-milestone-release.yml
index e449f6bee..1e7356531 100644
--- a/.github/workflows/docker-milestone-release.yml
+++ b/.github/workflows/docker-milestone-release.yml
@@ -7,9 +7,37 @@ on:
env:
DOCKER_NAMESPACE: eclipsebasyx
+permissions: {}
+
jobs:
+ guard:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ should-run: ${{ steps.check.outputs.should-run }}
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v7
+
+ - id: check
+ env:
+ EVENT_REPOSITORY_FORK: ${{ github.event.repository.fork }}
+ run: |
+ should_run=$(bash .github/workflows/scripts/check_docker_publication.sh "${GITHUB_REPOSITORY}" "${EVENT_REPOSITORY_FORK}")
+ echo "should-run=${should_run}" >> "$GITHUB_OUTPUT"
+ if [[ "${should_run}" == "true" ]]; then
+ echo "Running Docker publication in the upstream repository"
+ else
+ echo "Skipping Docker publication for ${GITHUB_REPOSITORY}"
+ fi
+
build-and-push-milestone-release:
+ needs: guard
+ if: ${{ needs.guard.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
+ permissions:
+ contents: read
strategy:
matrix:
include:
diff --git a/.github/workflows/docker-publication-guard-test.yml b/.github/workflows/docker-publication-guard-test.yml
new file mode 100644
index 000000000..971661f51
--- /dev/null
+++ b/.github/workflows/docker-publication-guard-test.yml
@@ -0,0 +1,58 @@
+# /*******************************************************************************
+# * Copyright (C) 2026 the Eclipse BaSyx Authors
+# *
+# * Permission is hereby granted, free of charge, to any person obtaining
+# * a copy of this software and associated documentation files (the
+# * "Software"), to deal in the Software without restriction, including
+# * without limitation the rights to use, copy, modify, merge, publish,
+# * distribute, sublicense, and/or sell copies of the Software, and to
+# * permit persons to whom the Software is furnished to do so, subject to
+# * the following conditions:
+# *
+# * The above copyright notice and this permission notice shall be
+# * included in all copies or substantial portions of the Software.
+# *
+# * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+# *
+# * SPDX-License-Identifier: MIT
+# ******************************************************************************/
+
+name: Test Docker Publication Guard
+
+on:
+ pull_request:
+ branches: [ main ]
+ paths:
+ - '.github/workflows/docker-milestone-release.yml'
+ - '.github/workflows/docker-snapshot-release.yml'
+ - '.github/workflows/docker-publication-guard-test.yml'
+ - '.github/workflows/scripts/check_docker_publication.sh'
+ - '.github/workflows/scripts/test_docker_publication_guard.sh'
+ push:
+ branches: [ main ]
+ paths:
+ - '.github/workflows/docker-milestone-release.yml'
+ - '.github/workflows/docker-snapshot-release.yml'
+ - '.github/workflows/docker-publication-guard-test.yml'
+ - '.github/workflows/scripts/check_docker_publication.sh'
+ - '.github/workflows/scripts/test_docker_publication_guard.sh'
+
+permissions: {}
+
+jobs:
+ test-docker-publication-guard:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v7
+
+ - name: Test publication guard
+ run: bash .github/workflows/scripts/test_docker_publication_guard.sh
diff --git a/.github/workflows/docker-snapshot-release.yml b/.github/workflows/docker-snapshot-release.yml
index bb7362170..1913176f0 100644
--- a/.github/workflows/docker-snapshot-release.yml
+++ b/.github/workflows/docker-snapshot-release.yml
@@ -23,9 +23,37 @@ on:
env:
DOCKER_NAMESPACE: eclipsebasyx
+permissions: {}
+
jobs:
+ guard:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ should-run: ${{ steps.check.outputs.should-run }}
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v7
+
+ - id: check
+ env:
+ EVENT_REPOSITORY_FORK: ${{ github.event.repository.fork }}
+ run: |
+ should_run=$(bash .github/workflows/scripts/check_docker_publication.sh "${GITHUB_REPOSITORY}" "${EVENT_REPOSITORY_FORK}")
+ echo "should-run=${should_run}" >> "$GITHUB_OUTPUT"
+ if [[ "${should_run}" == "true" ]]; then
+ echo "Running Docker publication in the upstream repository"
+ else
+ echo "Skipping Docker publication for ${GITHUB_REPOSITORY}"
+ fi
+
build-and-push-prerelease:
+ needs: guard
+ if: ${{ needs.guard.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
+ permissions:
+ contents: read
strategy:
matrix:
include:
diff --git a/.github/workflows/scripts/check_docker_publication.sh b/.github/workflows/scripts/check_docker_publication.sh
new file mode 100755
index 000000000..77ed06828
--- /dev/null
+++ b/.github/workflows/scripts/check_docker_publication.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+
+# /*******************************************************************************
+# * Copyright (C) 2026 the Eclipse BaSyx Authors
+# *
+# * Permission is hereby granted, free of charge, to any person obtaining
+# * a copy of this software and associated documentation files (the
+# * "Software"), to deal in the Software without restriction, including
+# * without limitation the rights to use, copy, modify, merge, publish,
+# * distribute, sublicense, and/or sell copies of the Software, and to
+# * permit persons to whom the Software is furnished to do so, subject to
+# * the following conditions:
+# *
+# * The above copyright notice and this permission notice shall be
+# * included in all copies or substantial portions of the Software.
+# *
+# * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+# *
+# * SPDX-License-Identifier: MIT
+# ******************************************************************************/
+
+set -euo pipefail
+
+readonly UPSTREAM_REPOSITORY="eclipse-basyx/basyx-java-server-sdk"
+
+repository="${1:-}"
+is_fork="${2:-}"
+
+if [[ "${repository}" == "${UPSTREAM_REPOSITORY}" && "${is_fork}" == "false" ]]; then
+ printf 'true\n'
+else
+ printf 'false\n'
+fi
diff --git a/.github/workflows/scripts/test_docker_publication_guard.sh b/.github/workflows/scripts/test_docker_publication_guard.sh
new file mode 100755
index 000000000..96cae1449
--- /dev/null
+++ b/.github/workflows/scripts/test_docker_publication_guard.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+
+# /*******************************************************************************
+# * Copyright (C) 2026 the Eclipse BaSyx Authors
+# *
+# * Permission is hereby granted, free of charge, to any person obtaining
+# * a copy of this software and associated documentation files (the
+# * "Software"), to deal in the Software without restriction, including
+# * without limitation the rights to use, copy, modify, merge, publish,
+# * distribute, sublicense, and/or sell copies of the Software, and to
+# * permit persons to whom the Software is furnished to do so, subject to
+# * the following conditions:
+# *
+# * The above copyright notice and this permission notice shall be
+# * included in all copies or substantial portions of the Software.
+# *
+# * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+# *
+# * SPDX-License-Identifier: MIT
+# ******************************************************************************/
+
+set -euo pipefail
+
+readonly SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+readonly GUARD_SCRIPT="${SCRIPT_DIRECTORY}/check_docker_publication.sh"
+readonly WORKFLOW_DIRECTORY="${SCRIPT_DIRECTORY}/.."
+
+assert_guard_result() {
+ local description="$1"
+ local repository="$2"
+ local is_fork="$3"
+ local expected="$4"
+ local actual
+
+ actual="$(bash "${GUARD_SCRIPT}" "${repository}" "${is_fork}")"
+ if [[ "${actual}" != "${expected}" ]]; then
+ echo "${description}: expected ${expected}, got ${actual}" >&2
+ exit 1
+ fi
+}
+
+assert_workflow_uses_guard() {
+ local workflow="$1"
+
+ if [[ "$(grep -c 'check_docker_publication.sh' "${workflow}")" -ne 1 ]]; then
+ echo "${workflow} must invoke the shared Docker publication guard exactly once" >&2
+ exit 1
+ fi
+}
+
+# Release workflow cases
+assert_guard_result "release in upstream" "eclipse-basyx/basyx-java-server-sdk" "false" "true"
+assert_guard_result "release in upstream marked as fork" "eclipse-basyx/basyx-java-server-sdk" "true" "false"
+assert_guard_result "release in fork" "contributor/basyx-java-server-sdk" "true" "false"
+assert_guard_result "release in renamed repository" "eclipse-basyx/basyx-java-server-sdk-renamed" "false" "false"
+
+# Snapshot push workflow cases
+assert_guard_result "push in upstream" "eclipse-basyx/basyx-java-server-sdk" "false" "true"
+assert_guard_result "push in upstream marked as fork" "eclipse-basyx/basyx-java-server-sdk" "true" "false"
+assert_guard_result "push in fork" "contributor/basyx-java-server-sdk" "true" "false"
+assert_guard_result "push in renamed repository" "eclipse-basyx/basyx-java-server-sdk-renamed" "false" "false"
+
+# Missing event metadata must fail closed.
+assert_guard_result "missing fork metadata" "eclipse-basyx/basyx-java-server-sdk" "" "false"
+assert_guard_result "missing repository metadata" "" "false" "false"
+
+assert_workflow_uses_guard "${WORKFLOW_DIRECTORY}/docker-milestone-release.yml"
+assert_workflow_uses_guard "${WORKFLOW_DIRECTORY}/docker-snapshot-release.yml"
+
+echo "Docker publication guard tests passed"
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/Readme.md b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/Readme.md
index a3f13cc22..940c50a86 100644
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/Readme.md
+++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/Readme.md
@@ -6,5 +6,9 @@ This feature provides hierarchical MQTT eventing for a multitude of events:
| AAS Created | aas-repository/\$repoId/shells/created| Created AAS JSON |
| AAS Updated | aas-repository/\$repoId/shells/updated| Updated AAS JSON|
| AAS Deleted | aas-repository/\$repoId/shells/deleted| Deleted AAS JSON|
-| Submodel Reference Created | aas-repository/\$repoId/shells/submodels/\$submodelId/created| Created AAS JSON |
-| Submodel Reference Deleted | aas-repository/\$repoId/shells/submodels/\$submodelId/deleted| Deleted AAS JSON|
\ No newline at end of file
+| Submodel Reference Created | aas-repository/\$repoId/shells/submodels/\$submodelIdBase64Url/created| Created AAS JSON |
+| Submodel Reference Deleted | aas-repository/\$repoId/shells/submodels/\$submodelIdBase64Url/deleted| Deleted AAS JSON|
+
+Identifier segments use unpadded UTF-8 Base64URL encoding. Repository names are not encoded.
+
+> **Breaking topic migration:** Submodel-reference events are published only on the encoded canonical topics. Existing subscribers using raw submodel IDs must update their subscriptions.
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/pom.xml b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/pom.xml
index df0214d78..df7ae3045 100644
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/pom.xml
+++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/pom.xml
@@ -21,6 +21,13 @@
org.eclipse.digitaltwin.basyx
basyx.mqttcore
+
+ org.eclipse.digitaltwin.basyx
+ basyx.mqttcore
+ ${revision}
+ tests
+ test
+
org.eclipse.digitaltwin.basyx
basyx.aasrepository-core
@@ -55,4 +62,4 @@
test
-
\ No newline at end of file
+
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java
index f42d9e96f..14657aec6 100644
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java
+++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java
@@ -35,14 +35,12 @@
import org.eclipse.digitaltwin.aas4j.v3.model.Reference;
import org.eclipse.digitaltwin.aas4j.v3.model.SpecificAssetId;
import org.eclipse.digitaltwin.basyx.aasrepository.AasRepository;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttEventPublisher;
import org.eclipse.digitaltwin.basyx.core.exceptions.CollidingIdentifierException;
import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException;
import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult;
import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo;
import org.eclipse.paho.client.mqttv3.IMqttClient;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttMessage;
-import org.eclipse.paho.client.mqttv3.MqttPersistenceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -170,24 +168,7 @@ private String serializePayload(AssetAdministrationShell shell) {
* the actual message
*/
private void sendMqttMessage(String topic, String payload) {
- MqttMessage msg = createMqttMessage(payload);
-
- try {
- logger.debug("Send MQTT message to " + topic + ": " + payload);
- mqttClient.publish(topic, msg);
- } catch (MqttPersistenceException e) {
- logger.error("Could not persist mqtt message", e);
- } catch (MqttException e) {
- logger.error("Could not send mqtt message", e);
- }
- }
-
- private MqttMessage createMqttMessage(String payload) {
- if (payload == null) {
- return new MqttMessage();
- } else {
- return new MqttMessage(payload.getBytes());
- }
+ MqttEventPublisher.publish(mqttClient, topic, payload, logger);
}
@Override
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryConfiguration.java b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryConfiguration.java
index 7e2c3de71..8cc94c084 100644
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryConfiguration.java
+++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryConfiguration.java
@@ -26,54 +26,11 @@
package org.eclipse.digitaltwin.basyx.aasrepository.feature.mqtt;
-import org.eclipse.paho.client.mqttv3.IMqttClient;
-import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
-import org.springframework.beans.factory.annotation.Value;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttClientConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ConditionalOnExpression("#{${" + MqttAasRepositoryFeature.FEATURENAME + ".enabled:false} or ${basyx.feature.mqtt.enabled:false}}")
@Configuration
-public class MqttAasRepositoryConfiguration {
-
- @ConditionalOnMissingBean
- @Bean
- public IMqttClient mqttClient(
- @Value("${mqtt.clientId}") String clientId,
- @Value("${mqtt.hostname}") String hostname,
- @Value("${mqtt.port}") int port,
- @Value("${mqtt.protocol:tcp}") String protocol,
- MqttConnectOptions mqttConnectOptions)
- throws MqttException {
- IMqttClient mqttClient = new MqttClient(protocol + "://" + hostname + ":" + port, clientId,
- new MemoryPersistence());
-
- mqttClient.connect(mqttConnectOptions);
-
- return mqttClient;
- }
-
- @ConditionalOnMissingBean
- @Bean
- @ConfigurationProperties(prefix = "mqtt")
- public MqttConnectOptions mqttConnectOptions(
- @Value("${mqtt.username:}") String username,
- @Value("${mqtt.password:}") String password) {
- MqttConnectOptions mqttConnectOptions = new MqttConnectOptions();
- mqttConnectOptions.setAutomaticReconnect(true);
-
- if (username.isBlank() || password.isBlank())
- return mqttConnectOptions;
-
- mqttConnectOptions.setUserName(username);
- mqttConnectOptions.setPassword(password.toCharArray());
-
- return mqttConnectOptions;
- }
+public class MqttAasRepositoryConfiguration extends MqttClientConfiguration {
}
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryFeature.java b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryFeature.java
index 6bb7905f9..ee7b5f926 100644
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryFeature.java
+++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryFeature.java
@@ -28,7 +28,7 @@
import org.eclipse.digitaltwin.basyx.aasrepository.AasRepositoryFactory;
import org.eclipse.digitaltwin.basyx.aasrepository.feature.AasRepositoryFeature;
-import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.URLEncoder;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
import org.eclipse.paho.client.mqttv3.IMqttClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -52,7 +52,7 @@ public MqttAasRepositoryFeature(IMqttClient mqttClient) {
@Override
public AasRepositoryFactory decorate(AasRepositoryFactory aasServiceFactory) {
- return new MqttAasRepositoryFactory(aasServiceFactory, mqttClient, new MqttAasRepositoryTopicFactory(new URLEncoder()));
+ return new MqttAasRepositoryFactory(aasServiceFactory, mqttClient, new MqttAasRepositoryTopicFactory(new Base64URLEncoder()));
}
@Override
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryTopicFactory.java b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryTopicFactory.java
index f714d69e6..d6f3bde2f 100644
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryTopicFactory.java
+++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryTopicFactory.java
@@ -108,7 +108,7 @@ public String createCreateAASSubmodelReferenceTopic(String repoId, String refere
.add(repoId)
.add(SHELLS)
.add(SUBMODELS)
- .add(referenceId)
+ .add(encodeId(referenceId))
.add(CREATED)
.toString();
}
@@ -126,7 +126,7 @@ public String createDeleteAASSubmodelReferenceTopic(String repoId, String refere
.add(repoId)
.add(SHELLS)
.add(SUBMODELS)
- .add(referenceId)
+ .add(encodeId(referenceId))
.add(DELETED)
.toString();
}
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryTopicFactoryTest.java b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryTopicFactoryTest.java
new file mode 100644
index 000000000..664100cb1
--- /dev/null
+++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryTopicFactoryTest.java
@@ -0,0 +1,45 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.aasrepository.feature.mqtt;
+
+import static org.junit.Assert.assertEquals;
+
+import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
+import org.junit.Test;
+
+public class MqttAasRepositoryTopicFactoryTest {
+ private static final String SUBMODEL_ID = "https://example.org/submodels/ä?x=1";
+ private static final String ENCODED_SUBMODEL_ID = "aHR0cHM6Ly9leGFtcGxlLm9yZy9zdWJtb2RlbHMvw6Q_eD0x";
+
+ private final MqttAasRepositoryTopicFactory topicFactory = new MqttAasRepositoryTopicFactory(new Base64URLEncoder());
+
+ @Test
+ public void encodesSubmodelReferenceIdentifiers() {
+ assertEquals("aas-repository/custom-repo/shells/submodels/" + ENCODED_SUBMODEL_ID + "/created",
+ topicFactory.createCreateAASSubmodelReferenceTopic("custom-repo", SUBMODEL_ID));
+ assertEquals("aas-repository/custom-repo/shells/submodels/" + ENCODED_SUBMODEL_ID + "/deleted",
+ topicFactory.createDeleteAASSubmodelReferenceTopic("custom-repo", SUBMODEL_ID));
+ }
+}
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/TestMqttV2AASAggregatorObserver.java b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/TestMqttV2AASAggregatorObserver.java
index cba715c69..50838616a 100644
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/TestMqttV2AASAggregatorObserver.java
+++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/TestMqttV2AASAggregatorObserver.java
@@ -41,24 +41,17 @@
import org.eclipse.digitaltwin.basyx.aasrepository.DummyAasFactory;
import org.eclipse.digitaltwin.basyx.aasrepository.backend.CrudAasRepositoryFactory;
import org.eclipse.digitaltwin.basyx.aasservice.backend.InMemoryAasBackend;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttBrokerTestSupport;
import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
-import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.URLEncoder;
import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener.MqttEvent;
import org.eclipse.digitaltwin.basyx.core.filerepository.InMemoryFileRepository;
-import org.eclipse.paho.client.mqttv3.IMqttClient;
import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttSecurityException;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
-import io.moquette.broker.Server;
-import io.moquette.broker.config.ClasspathResourceLoader;
-import io.moquette.broker.config.IConfig;
-import io.moquette.broker.config.IResourceLoader;
-import io.moquette.broker.config.ResourceLoaderConfig;
/**
* Tests events emitting with the MqttAASAggregatorObserver
@@ -67,7 +60,7 @@
*
*/
public class TestMqttV2AASAggregatorObserver {
- private static Server mqttBroker;
+ private static MqttBrokerTestSupport mqttBroker;
private static MqttClient mqttClient;
private static MqttTestListener listener;
private static MqttAasRepositoryTopicFactory topicFactory = new MqttAasRepositoryTopicFactory(new Base64URLEncoder());
@@ -76,19 +69,16 @@ public class TestMqttV2AASAggregatorObserver {
@BeforeClass
public static void setUpClass() throws MqttException, IOException {
- mqttBroker = startBroker();
-
- listener = configureInterceptListener(mqttBroker);
-
- mqttClient = createAndConnectClient();
+ mqttBroker = MqttBrokerTestSupport.start();
+ listener = mqttBroker.listener();
+ mqttClient = mqttBroker.connectClient();
aasRepository = createMqttAasRepository(mqttClient);
}
@AfterClass
- public static void tearDownClass() {
- mqttBroker.removeInterceptHandler(listener);
- mqttBroker.stopServer();
+ public static void tearDownClass() throws MqttException {
+ mqttBroker.close();
}
@Test
@@ -96,8 +86,8 @@ public void createAasEvent() throws DeserializationException {
AssetAdministrationShell shell = createAasDummy("createAasEventId");
aasRepository.createAas(shell);
- assertEquals(topicFactory.createCreateAASTopic(aasRepository.getName()), listener.lastTopic);
- assertEquals(shell, deserializePayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateAASTopic(aasRepository.getName()));
+ assertEquals(shell, deserializePayload(event.payload()));
}
@Test
@@ -109,8 +99,8 @@ public void updateAasEvent() throws DeserializationException {
aasRepository.updateAas(shell.getId(), shell);
- assertEquals(topicFactory.createUpdateAASTopic(aasRepository.getName()), listener.lastTopic);
- assertEquals(shell, deserializePayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createUpdateAASTopic(aasRepository.getName()));
+ assertEquals(shell, deserializePayload(event.payload()));
}
@Test
@@ -119,8 +109,8 @@ public void deleteAasEvent() throws DeserializationException {
aasRepository.createAas(shell);
aasRepository.deleteAas(shell.getId());
- assertEquals(topicFactory.createDeleteAASTopic(aasRepository.getName()), listener.lastTopic);
- assertEquals(shell, deserializePayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createDeleteAASTopic(aasRepository.getName()));
+ assertEquals(shell, deserializePayload(event.payload()));
}
@Test
@@ -131,8 +121,8 @@ public void addSubmodelReferenceEvent() throws DeserializationException {
Reference submodelReference = DummyAasFactory.createDummyReference(DummyAasFactory.DUMMY_SUBMODEL_ID);
aasRepository.addSubmodelReference(shell.getId(), submodelReference);
- assertEquals(topicFactory.createCreateAASSubmodelReferenceTopic(aasRepository.getName(), DummyAasFactory.DUMMY_SUBMODEL_ID), listener.lastTopic);
- assertEquals(shell, deserializePayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateAASSubmodelReferenceTopic(aasRepository.getName(), DummyAasFactory.DUMMY_SUBMODEL_ID));
+ assertEquals(shell, deserializePayload(event.payload()));
}
@Test
@@ -142,8 +132,8 @@ public void removeSubmodelReferenceEvent() throws DeserializationException {
aasRepository.removeSubmodelReference(shell.getId(), DummyAasFactory.DUMMY_SUBMODEL_ID);
- assertEquals(topicFactory.createDeleteAASSubmodelReferenceTopic(aasRepository.getName(), DummyAasFactory.DUMMY_SUBMODEL_ID), listener.lastTopic);
- assertEquals(shell, deserializePayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createDeleteAASSubmodelReferenceTopic(aasRepository.getName(), DummyAasFactory.DUMMY_SUBMODEL_ID));
+ assertEquals(shell, deserializePayload(event.payload()));
}
private AssetAdministrationShell deserializePayload(String payload) throws DeserializationException {
@@ -173,110 +163,6 @@ private AssetAdministrationShell createAasWithSubmodelReference(String aasId) {
private static AasRepository createMqttAasRepository(MqttClient client) {
AasRepositoryFactory repoFactory = CrudAasRepositoryFactory.builder().backend(new InMemoryAasBackend()).fileRepository(new InMemoryFileRepository()).buildFactory();
- return new MqttAasRepositoryFactory(repoFactory, client, new MqttAasRepositoryTopicFactory(new URLEncoder())).create();
- }
-
- private static MqttTestListener configureInterceptListener(Server broker) {
- MqttTestListener testListener = new MqttTestListener();
- broker.addInterceptHandler(testListener);
-
- return testListener;
- }
-
- private static MqttClient createAndConnectClient() throws MqttException, MqttSecurityException {
- MqttClient client = new MqttClient("tcp://localhost:1884", "testClient");
- client.connect();
- return client;
- }
-
- private static Server startBroker() throws IOException {
- Server broker = new Server();
- IResourceLoader classpathLoader = new ClasspathResourceLoader();
-
- IConfig classPathConfig = new ResourceLoaderConfig(classpathLoader);
- broker.startServer(classPathConfig);
-
- return broker;
- }
-
- @Test
- public void checkTCPConnectionWithoutCredentials() throws Exception {
- MqttAasRepositoryConfiguration config = new MqttAasRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("", "");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
+ return new MqttAasRepositoryFactory(repoFactory, client, new MqttAasRepositoryTopicFactory(new Base64URLEncoder())).create();
}
-
- @Test
- public void checkTCPConnectionWitCredentials() throws Exception {
- MqttAasRepositoryConfiguration config = new MqttAasRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "passwd");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkTCPConnectionWitWrongCredentials() throws Exception {
- MqttAasRepositoryConfiguration config = new MqttAasRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "false");
- boolean authentication_failed = false;
- try {
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- } catch (MqttException e) {
- if (MqttException.REASON_CODE_FAILED_AUTHENTICATION == e.getReasonCode()) {
- authentication_failed = true;
- }
- }
- assertTrue(authentication_failed);
- }
-
- @Test
- public void checkWSConnectionWithoutCredentials() throws Exception {
- MqttAasRepositoryConfiguration config = new MqttAasRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("", "");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 8080,
- "ws",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkWSConnectionWitCredentials() throws Exception {
- MqttAasRepositoryConfiguration config = new MqttAasRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "passwd");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 8080,
- "ws",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
}
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/resources/config/moquette.conf b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/resources/config/moquette.conf
deleted file mode 100644
index 94e6f3440..000000000
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/resources/config/moquette.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-# Moquette Java Broker configuration file for testing
-
-# Do not use the default 1883 port
-port 1884
-websocket_port 8080
-host 0.0.0.0
-password_file config/password_file.conf
-allow_anonymous true
\ No newline at end of file
diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/resources/config/password_file.conf b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/resources/config/password_file.conf
deleted file mode 100644
index e8062773e..000000000
--- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/resources/config/password_file.conf
+++ /dev/null
@@ -1 +0,0 @@
-testuser:0d6be69b264717f2dd33652e212b173104b4a647b7c11ae72e9885f11cd312fb
\ No newline at end of file
diff --git a/basyx.aasrepository/basyx.aasrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/component/TestAasRepositoryName.java b/basyx.aasrepository/basyx.aasrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/component/TestAasRepositoryName.java
index 7cb9aeb40..8770eebe3 100644
--- a/basyx.aasrepository/basyx.aasrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/component/TestAasRepositoryName.java
+++ b/basyx.aasrepository/basyx.aasrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/component/TestAasRepositoryName.java
@@ -26,11 +26,21 @@
package org.eclipse.digitaltwin.basyx.aasrepository.component;
import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import java.util.Map;
+
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultAssetAdministrationShell;
import org.eclipse.digitaltwin.basyx.aasrepository.AasRepository;
+import org.eclipse.paho.client.mqttv3.IMqttClient;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.junit.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.env.SystemEnvironmentPropertySource;
/**
*
@@ -74,6 +84,22 @@ public void getConfiguredRepoName() {
resetRepoNamePropertyAndCloseContext();
}
+ @Test
+ public void environmentStyleRepositoryNameReachesMqttTopic() throws Exception {
+ IMqttClient mqttClient = mock(IMqttClient.class);
+ SpringApplication application = new SpringApplication(AasRepositoryComponent.class);
+ application.addInitializers(context -> {
+ context.getEnvironment().getPropertySources().addFirst(new SystemEnvironmentPropertySource("test-environment",
+ Map.of("BASYX_AASREPO_NAME", CONFIGURED_AAS_REPO_NAME, "BASYX_FEATURE_MQTT_ENABLED", "true")));
+ context.getBeanFactory().registerSingleton("mqttTestClient", mqttClient);
+ });
+
+ try (ConfigurableApplicationContext context = application.run()) {
+ context.getBean(AasRepository.class).createAas(new DefaultAssetAdministrationShell.Builder().id("mqtt-name-test-aas").build());
+ verify(mqttClient).publish(eq("aas-repository/" + CONFIGURED_AAS_REPO_NAME + "/shells/created"), any(MqttMessage.class));
+ }
+ }
+
private void resetRepoNamePropertyAndCloseContext() {
System.clearProperty(BASYX_AASREPO_NAME_KEY);
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/Readme.md b/basyx.aasservice/basyx.aasservice-feature-mqtt/Readme.md
index b01f94492..6a1425382 100644
--- a/basyx.aasservice/basyx.aasservice-feature-mqtt/Readme.md
+++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/Readme.md
@@ -3,6 +3,10 @@ This feature provides hierarchical MQTT eventing for a multitude of events:
| Event |Topic | Payload |
| ----------- | ----------- | --- |
-| AssetInformation Set | aas-repository/\$repoId/shells/\$shellId/assetInformation/updated | Created AssetInformation JSON |
-| SubmodelReference Added | aas-repository/\$repoId/shells/$shellId/submodelReferences/created | Created SubmodelReference JSON |
-| SubmodelReference Removed | aas-repository/\$repoId/shells/\$shellId/submodelReferences/deleted | Deleted SubmodelReference JSON |
+| AssetInformation Set | aas-repository/\$repoId/shells/\$shellIdBase64Url/assetInformation/updated | Created AssetInformation JSON |
+| SubmodelReference Added | aas-repository/\$repoId/shells/\$shellIdBase64Url/submodelReferences/created | Created SubmodelReference JSON |
+| SubmodelReference Removed | aas-repository/\$repoId/shells/\$shellIdBase64Url/submodelReferences/deleted | Deleted SubmodelReference JSON |
+
+Identifier segments use unpadded UTF-8 Base64URL encoding. Repository names are not encoded.
+
+> **Breaking topic migration:** AAS service events are published only on the encoded canonical topics. Existing subscribers using raw or percent-encoded shell IDs must update their subscriptions.
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/pom.xml b/basyx.aasservice/basyx.aasservice-feature-mqtt/pom.xml
index cb30ee8ec..35f1bfd04 100644
--- a/basyx.aasservice/basyx.aasservice-feature-mqtt/pom.xml
+++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/pom.xml
@@ -55,10 +55,17 @@
org.eclipse.digitaltwin.basyx
basyx.mqttcore
+
+ org.eclipse.digitaltwin.basyx
+ basyx.mqttcore
+ ${revision}
+ tests
+ test
+
org.eclipse.digitaltwin.basyx
basyx.http
test
-
\ No newline at end of file
+
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java
index 96e53e264..86be2ce68 100644
--- a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java
+++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java
@@ -33,13 +33,11 @@
import org.eclipse.digitaltwin.aas4j.v3.model.Key;
import org.eclipse.digitaltwin.aas4j.v3.model.Reference;
import org.eclipse.digitaltwin.basyx.aasservice.AasService;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttEventPublisher;
import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException;
import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult;
import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo;
import org.eclipse.paho.client.mqttv3.IMqttClient;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttMessage;
-import org.eclipse.paho.client.mqttv3.MqttPersistenceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -88,24 +86,7 @@ public String serialize(Object obj) {
* the actual message
*/
private void sendMqttMessage(String topic, String payload) {
- MqttMessage msg = createMqttMessage(payload);
-
- try {
- logger.debug("Send MQTT message to " + topic + ": " + payload);
- mqttClient.publish(topic, msg);
- } catch (MqttPersistenceException e) {
- logger.error("Could not persist mqtt message", e);
- } catch (MqttException e) {
- logger.error("Could not send mqtt message", e);
- }
- }
-
- private MqttMessage createMqttMessage(String payload) {
- if (payload == null) {
- return new MqttMessage();
- } else {
- return new MqttMessage(payload.getBytes());
- }
+ MqttEventPublisher.publish(mqttClient, topic, payload, logger);
}
@Override
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasServiceFeature.java b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasServiceFeature.java
index 244d2254c..dd631e1c8 100644
--- a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasServiceFeature.java
+++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasServiceFeature.java
@@ -27,7 +27,7 @@
import org.eclipse.digitaltwin.basyx.aasrepository.AasRepository;
import org.eclipse.digitaltwin.basyx.aasservice.AasServiceFactory;
import org.eclipse.digitaltwin.basyx.aasservice.feature.AasServiceFeature;
-import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.URLEncoder;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
import org.eclipse.paho.client.mqttv3.IMqttClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -57,7 +57,7 @@ public MqttAasServiceFeature(IMqttClient mqttClient, AasRepository repo, ObjectM
@Override
public AasServiceFactory decorate(AasServiceFactory aasServiceFactory) {
- return new MqttAasServiceFactory(aasServiceFactory, mqttClient, new MqttAasServiceTopicFactory(new URLEncoder()), repoId, objectMapper);
+ return new MqttAasServiceFactory(aasServiceFactory, mqttClient, new MqttAasServiceTopicFactory(new Base64URLEncoder()), repoId, objectMapper);
}
@Override
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttConfiguration.java b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttConfiguration.java
index b6993a630..567b0594c 100644
--- a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttConfiguration.java
+++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttConfiguration.java
@@ -24,53 +24,11 @@
******************************************************************************/
package org.eclipse.digitaltwin.basyx.aasservice.feature.mqtt;
-import org.eclipse.paho.client.mqttv3.IMqttClient;
-import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
-import org.springframework.beans.factory.annotation.Value;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttClientConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ConditionalOnExpression("#{${" + MqttAasServiceFeature.FEATURENAME + ".enabled:false} or ${basyx.feature.mqtt.enabled:false}}")
@Configuration
-public class MqttConfiguration {
-
- @ConditionalOnMissingBean
- @Bean
- public IMqttClient mqttClient(
- @Value("${mqtt.clientId}") String clientId,
- @Value("${mqtt.hostname}") String hostname,
- @Value("${mqtt.port}") int port,
- @Value("${mqtt.protocol:tcp}") String protocol,
- MqttConnectOptions mqttConnectOptions) throws MqttException {
- IMqttClient mqttClient = new MqttClient(protocol + "://" + hostname + ":" + port, clientId,
- new MemoryPersistence());
-
- mqttClient.connect(mqttConnectOptions);
-
- return mqttClient;
- }
-
- @ConditionalOnMissingBean
- @Bean
- @ConfigurationProperties(prefix = "mqtt")
- public MqttConnectOptions mqttConnectOptions(
- @Value("${mqtt.username:}") String username,
- @Value("${mqtt.password:}") String password) {
- MqttConnectOptions mqttConnectOptions = new MqttConnectOptions();
- mqttConnectOptions.setAutomaticReconnect(true);
-
- if (username.isBlank() || password.isBlank())
- return mqttConnectOptions;
-
- mqttConnectOptions.setUserName(username);
- mqttConnectOptions.setPassword(password.toCharArray());
-
- return mqttConnectOptions;
- }
+public class MqttConfiguration extends MqttClientConfiguration {
}
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasServiceTopicFactoryTest.java b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasServiceTopicFactoryTest.java
new file mode 100644
index 000000000..1a3101f37
--- /dev/null
+++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasServiceTopicFactoryTest.java
@@ -0,0 +1,45 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.aasservice.feature.mqtt;
+
+import static org.junit.Assert.assertEquals;
+
+import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
+import org.junit.Test;
+
+public class MqttAasServiceTopicFactoryTest {
+ private static final String AAS_ID = "https://example.org/aas/ä";
+ private static final String ENCODED_AAS_ID = "aHR0cHM6Ly9leGFtcGxlLm9yZy9hYXMvw6Q";
+
+ private final MqttAasServiceTopicFactory topicFactory = new MqttAasServiceTopicFactory(new Base64URLEncoder());
+
+ @Test
+ public void encodesShellIdentifiers() {
+ assertEquals("aas-repository/custom-repo/shells/" + ENCODED_AAS_ID + "/assetInformation/updated",
+ topicFactory.createSetAssetInformationTopic("custom-repo", AAS_ID));
+ assertEquals("aas-repository/custom-repo/shells/" + ENCODED_AAS_ID + "/submodelReferences/created",
+ topicFactory.createAddSubmodelReferenceTopic("custom-repo", AAS_ID));
+ }
+}
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/TestMqttAasService.java b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/TestMqttAasService.java
index ff35af98e..e01820181 100644
--- a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/TestMqttAasService.java
+++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/TestMqttAasService.java
@@ -27,11 +27,6 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.moquette.broker.Server;
-import io.moquette.broker.config.ClasspathResourceLoader;
-import io.moquette.broker.config.IConfig;
-import io.moquette.broker.config.IResourceLoader;
-import io.moquette.broker.config.ResourceLoaderConfig;
import org.eclipse.digitaltwin.aas4j.v3.dataformat.core.DeserializationException;
import org.eclipse.digitaltwin.aas4j.v3.model.*;
import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultAssetInformation;
@@ -44,19 +39,18 @@
import org.eclipse.digitaltwin.basyx.aasservice.DummyAssetAdministrationShellFactory;
import org.eclipse.digitaltwin.basyx.aasservice.backend.CrudAasServiceFactory;
import org.eclipse.digitaltwin.basyx.aasservice.backend.InMemoryAasBackend;
-import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.URLEncoder;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttBrokerTestSupport;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener.MqttEvent;
import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository;
import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepositoryHelper;
import org.eclipse.digitaltwin.basyx.core.filerepository.InMemoryFileRepository;
import org.eclipse.digitaltwin.basyx.http.Aas4JHTTPSerializationExtension;
import org.eclipse.digitaltwin.basyx.http.BaSyxHTTPConfiguration;
import org.eclipse.digitaltwin.basyx.http.SerializationExtension;
-import org.eclipse.paho.client.mqttv3.IMqttClient;
import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttSecurityException;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -66,14 +60,13 @@
import java.util.List;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
public class TestMqttAasService extends AasServiceSuite {
- private static Server mqttBroker;
+ private static MqttBrokerTestSupport mqttBroker;
private static MqttClient mqttClient;
private static MqttTestListener listener;
- private static MqttAasServiceTopicFactory topicFactory = new MqttAasServiceTopicFactory(new URLEncoder());
+ private static MqttAasServiceTopicFactory topicFactory = new MqttAasServiceTopicFactory(new Base64URLEncoder());
private static AasRepository aasRepository;
private static AasServiceFactory mqttAasServiceFactory;
@@ -85,18 +78,17 @@ public class TestMqttAasService extends AasServiceSuite {
@BeforeClass
public static void setUpClass() throws MqttException, IOException {
objectMapper = configureObjectMapper();
- mqttBroker = startBroker();
- listener = configureInterceptListener(mqttBroker);
- mqttClient = createAndConnectClient();
+ mqttBroker = MqttBrokerTestSupport.start();
+ listener = mqttBroker.listener();
+ mqttClient = mqttBroker.connectClient();
aasRepository = createMqttAasRepository();
mqttAasServiceFactory = createMqttAasServiceFactory(mqttClient);
}
@AfterClass
- public static void tearDownClass() {
- mqttBroker.removeInterceptHandler(listener);
- mqttBroker.stopServer();
+ public static void tearDownClass() throws MqttException {
+ mqttBroker.close();
}
@Override
@@ -137,8 +129,8 @@ public void setAssetInformation() {
aasService.setAssetInformation(assetInfo);
String repoId = aasRepository.getName();
- assertEquals(topicFactory.createSetAssetInformationTopic(repoId, shell.getId()), listener.lastTopic);
- assertEquals(serialize(assetInfo), listener.lastPayload);
+ MqttEvent event = listener.awaitEvent(topicFactory.createSetAssetInformationTopic(repoId, shell.getId()));
+ assertEquals(serialize(assetInfo), event.payload());
}
private AssetInformation createDummyAssetInformation() {
@@ -157,8 +149,8 @@ public void addSubmodelReferenceEvent() throws DeserializationException, JsonPro
aasService.addSubmodelReference(submodelReference);
String repoId = aasRepository.getName();
- assertEquals(topicFactory.createAddSubmodelReferenceTopic(repoId, shell.getId()), listener.lastTopic);
- assertEquals(serialize(submodelReference), listener.lastPayload);
+ MqttEvent event = listener.awaitEvent(topicFactory.createAddSubmodelReferenceTopic(repoId, shell.getId()));
+ assertEquals(serialize(submodelReference), event.payload());
}
private String serialize(Object obj) {
@@ -180,8 +172,8 @@ public void removeSubmodelReferenceEvent() throws DeserializationException, Json
DummyAssetAdministrationShellFactory.addDummySubmodelReference(aasService.getAAS());
aasService.removeSubmodelReference(DummyAssetAdministrationShellFactory.SUBMODEL_ID);
- assertEquals(topicFactory.createRemoveSubmodelReferenceTopic(repoId, shell.getId()), listener.lastTopic);
- assertEquals(serialize(DummyAssetAdministrationShellFactory.submodelReference), listener.lastPayload);
+ MqttEvent event = listener.awaitEvent(topicFactory.createRemoveSubmodelReferenceTopic(repoId, shell.getId()));
+ assertEquals(serialize(DummyAssetAdministrationShellFactory.submodelReference), event.payload());
}
private static ObjectMapper configureObjectMapper() {
@@ -194,106 +186,4 @@ private static AasRepository createMqttAasRepository() {
return CrudAasRepositoryFactory.builder().backend(new InMemoryAasBackend()).fileRepository(new InMemoryFileRepository()).create();
}
- private static MqttClient createAndConnectClient() throws MqttException, MqttSecurityException {
- MqttClient client = new MqttClient("tcp://localhost:1884", "testClient");
- client.connect();
- return client;
- }
-
- private static MqttTestListener configureInterceptListener(Server broker) {
- MqttTestListener testListener = new MqttTestListener();
- broker.addInterceptHandler(testListener);
-
- return testListener;
- }
-
- private static Server startBroker() throws IOException {
- Server broker = new Server();
- IResourceLoader classpathLoader = new ClasspathResourceLoader();
-
- IConfig classPathConfig = new ResourceLoaderConfig(classpathLoader);
- broker.startServer(classPathConfig);
-
- return broker;
- }
-
- @Test
- public void checkTCPConnectionWithoutCredentials() throws Exception {
- MqttConfiguration config = new MqttConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("", "");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkTCPConnectionWitCredentials() throws Exception {
- MqttConfiguration config = new MqttConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "passwd");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkTCPConnectionWitWrongCredentials() throws Exception {
- MqttConfiguration config = new MqttConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "false");
- boolean authentication_failed = false;
- try {
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- } catch (MqttException e) {
- if (MqttException.REASON_CODE_FAILED_AUTHENTICATION == e.getReasonCode()) {
- authentication_failed = true;
- }
- }
- assertTrue(authentication_failed);
- }
-
- @Test
- public void checkWSConnectionWithoutCredentials() throws Exception {
- MqttConfiguration config = new MqttConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("", "");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 8080,
- "ws",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkWSConnectionWitCredentials() throws Exception {
- MqttConfiguration config = new MqttConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "passwd");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 8080,
- "ws",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
}
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/resources/config/moquette.conf b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/resources/config/moquette.conf
deleted file mode 100644
index 94e6f3440..000000000
--- a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/resources/config/moquette.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-# Moquette Java Broker configuration file for testing
-
-# Do not use the default 1883 port
-port 1884
-websocket_port 8080
-host 0.0.0.0
-password_file config/password_file.conf
-allow_anonymous true
\ No newline at end of file
diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/resources/config/password_file.conf b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/resources/config/password_file.conf
deleted file mode 100644
index e8062773e..000000000
--- a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/resources/config/password_file.conf
+++ /dev/null
@@ -1 +0,0 @@
-testuser:0d6be69b264717f2dd33652e212b173104b4a647b7c11ae72e9885f11cd312fb
\ No newline at end of file
diff --git a/basyx.common/basyx.mqttcore/pom.xml b/basyx.common/basyx.mqttcore/pom.xml
index c5c4ac8ad..a363af4d3 100644
--- a/basyx.common/basyx.mqttcore/pom.xml
+++ b/basyx.common/basyx.mqttcore/pom.xml
@@ -18,15 +18,19 @@
org.eclipse.paho
org.eclipse.paho.client.mqttv3
+
+ org.springframework.boot
+ spring-boot-starter
+
io.moquette
moquette-broker
-
-
- org.slf4j
- slf4j-reload4j
-
-
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
-
\ No newline at end of file
+
diff --git a/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttClientConfiguration.java b/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttClientConfiguration.java
new file mode 100644
index 000000000..5745596cf
--- /dev/null
+++ b/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttClientConfiguration.java
@@ -0,0 +1,74 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.common.mqttcore;
+
+import org.eclipse.paho.client.mqttv3.IMqttClient;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * Shared Paho client configuration used by all MQTT features.
+ */
+public class MqttClientConfiguration {
+ @ConditionalOnMissingBean
+ @Bean
+ public IMqttClient mqttClient(@Value("${mqtt.clientId}") String clientId, @Value("${mqtt.hostname}") String hostname, @Value("${mqtt.port}") int port,
+ @Value("${mqtt.protocol:tcp}") String protocol, MqttConnectOptions mqttConnectOptions) throws MqttException {
+ IMqttClient mqttClient = new MqttClient(protocol + "://" + hostname + ":" + port, clientId, new MemoryPersistence());
+ try {
+ mqttClient.connect(mqttConnectOptions);
+ return mqttClient;
+ } catch (MqttException connectError) {
+ try {
+ mqttClient.close();
+ } catch (MqttException closeError) {
+ connectError.addSuppressed(closeError);
+ }
+ throw connectError;
+ }
+ }
+
+ @ConditionalOnMissingBean
+ @Bean
+ @ConfigurationProperties(prefix = "mqtt")
+ public MqttConnectOptions mqttConnectOptions(@Value("${mqtt.username:}") String username, @Value("${mqtt.password:}") String password) {
+ MqttConnectOptions mqttConnectOptions = new MqttConnectOptions();
+ mqttConnectOptions.setAutomaticReconnect(true);
+
+ if (username.isBlank() || password.isBlank()) {
+ return mqttConnectOptions;
+ }
+
+ mqttConnectOptions.setUserName(username);
+ mqttConnectOptions.setPassword(password.toCharArray());
+ return mqttConnectOptions;
+ }
+}
diff --git a/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttEventPublisher.java b/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttEventPublisher.java
new file mode 100644
index 000000000..dcfe9f5ba
--- /dev/null
+++ b/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttEventPublisher.java
@@ -0,0 +1,50 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.common.mqttcore;
+
+import java.nio.charset.StandardCharsets;
+
+import org.eclipse.paho.client.mqttv3.IMqttClient;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+import org.slf4j.Logger;
+
+/**
+ * Publishes MQTT event payloads consistently across all feature decorators.
+ */
+public final class MqttEventPublisher {
+ private MqttEventPublisher() {
+ }
+
+ public static void publish(IMqttClient mqttClient, String topic, String payload, Logger logger) {
+ byte[] payloadBytes = payload == null ? new byte[0] : payload.getBytes(StandardCharsets.UTF_8);
+ try {
+ logger.debug("Send MQTT message to {}: {}", topic, payload);
+ mqttClient.publish(topic, new MqttMessage(payloadBytes));
+ } catch (MqttException e) {
+ logger.error("Could not send MQTT message to topic '" + topic + "'", e);
+ }
+ }
+}
diff --git a/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/listener/MqttTestListener.java b/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/listener/MqttTestListener.java
deleted file mode 100644
index 7fec3f1c4..000000000
--- a/basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/listener/MqttTestListener.java
+++ /dev/null
@@ -1,96 +0,0 @@
-/*******************************************************************************
- * Copyright (C) 2021 the Eclipse BaSyx Authors
- *
- * Permission is hereby granted, free of charge, to any person obtaining
- * a copy of this software and associated documentation files (the
- * "Software"), to deal in the Software without restriction, including
- * without limitation the rights to use, copy, modify, merge, publish,
- * distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to
- * the following conditions:
- *
- * The above copyright notice and this permission notice shall be
- * included in all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
- * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
- * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
- * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
- * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- *
- * SPDX-License-Identifier: MIT
- ******************************************************************************/
-package org.eclipse.digitaltwin.basyx.common.mqttcore.listener;
-
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-
-import io.moquette.interception.InterceptHandler;
-import io.moquette.interception.messages.InterceptAcknowledgedMessage;
-import io.moquette.interception.messages.InterceptConnectMessage;
-import io.moquette.interception.messages.InterceptConnectionLostMessage;
-import io.moquette.interception.messages.InterceptDisconnectMessage;
-import io.moquette.interception.messages.InterceptPublishMessage;
-import io.moquette.interception.messages.InterceptSubscribeMessage;
-import io.moquette.interception.messages.InterceptUnsubscribeMessage;
-
-/**
- * Very simple MQTT broker listener for testing API events. Stores the last
- * received event and makes its topic and payload available for reading.
- *
- * @author espen
- *
- */
-public class MqttTestListener implements InterceptHandler {
- // Topic and payload of the most recent event
- public String lastTopic;
- public String lastPayload;
- private ArrayList topics = new ArrayList<>();
-
- @Override
- public String getID() {
- return null;
- }
-
- @Override
- public Class>[] getInterceptedMessageTypes() {
- return null;
- }
-
- @Override
- public void onConnect(InterceptConnectMessage arg0) {
- }
-
- @Override
- public void onConnectionLost(InterceptConnectionLostMessage arg0) {
- }
-
- @Override
- public void onDisconnect(InterceptDisconnectMessage arg0) {
- }
-
- @Override
- public void onMessageAcknowledged(InterceptAcknowledgedMessage arg0) {
- }
-
- @Override
- public synchronized void onPublish(InterceptPublishMessage msg) {
- topics.add(msg.getTopicName());
- lastTopic = msg.getTopicName();
- lastPayload = msg.getPayload().toString(StandardCharsets.UTF_8);
- }
-
- @Override
- public void onSubscribe(InterceptSubscribeMessage arg0) {
- }
-
- @Override
- public void onUnsubscribe(InterceptUnsubscribeMessage arg0) {
- }
-
- public ArrayList getTopics() {
- return topics;
- }
-}
diff --git a/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttBrokerTestSupport.java b/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttBrokerTestSupport.java
new file mode 100644
index 000000000..401e8bc8d
--- /dev/null
+++ b/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttBrokerTestSupport.java
@@ -0,0 +1,202 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.common.mqttcore;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.IntSupplier;
+
+import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener;
+import org.eclipse.paho.client.mqttv3.IMqttClient;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttException;
+
+import io.moquette.broker.Server;
+import io.moquette.broker.config.FluentConfig;
+import io.moquette.broker.config.IConfig;
+
+/**
+ * Isolated, in-memory Moquette fixture shared by MQTT feature tests.
+ */
+public final class MqttBrokerTestSupport implements AutoCloseable {
+ private static final Duration PORT_BIND_TIMEOUT = Duration.ofSeconds(10);
+ private static final String WEBSOCKET_TRANSPORT_NAME = "Websocket MQTT";
+ private final MqttTestListener listener = new MqttTestListener();
+ private final Server broker;
+ private final List clients = new ArrayList<>();
+ private final int port;
+ private final int websocketPort;
+
+ private MqttBrokerTestSupport(IConfig config, boolean websocketEnabled) throws IOException {
+ broker = new Server();
+ try {
+ broker.startServer(config, List.of(listener));
+ port = awaitBoundPort(broker::getPort, "TCP");
+ websocketPort = websocketEnabled ? awaitBoundPort(() -> readTransportPort(broker, WEBSOCKET_TRANSPORT_NAME), "WebSocket") : -1;
+ } catch (IOException | RuntimeException | Error startupError) {
+ try {
+ broker.stopServer();
+ } catch (Throwable cleanupError) {
+ startupError.addSuppressed(cleanupError);
+ }
+ throw startupError;
+ }
+ }
+
+ public static MqttBrokerTestSupport start() throws IOException {
+ return start(new FluentConfig().host("localhost").port(0).allowAnonymous().disablePersistence().disableTelemetry().build());
+ }
+
+ public static MqttBrokerTestSupport start(IConfig config) throws IOException {
+ return new MqttBrokerTestSupport(config, false);
+ }
+
+ public static MqttBrokerTestSupport startWithWebSocket() throws IOException {
+ IConfig config = new FluentConfig().host("localhost").port(0).websocketPort(0).allowAnonymous().disablePersistence().disableTelemetry().build();
+ return new MqttBrokerTestSupport(config, true);
+ }
+
+ public MqttClient connectClient() throws MqttException {
+ MqttClient client = new MqttClient(serverUri(), uniqueClientId());
+ clients.add(client);
+ client.connect();
+ return client;
+ }
+
+ public T trackClient(T client) {
+ clients.add(client);
+ return client;
+ }
+
+ public String serverUri() {
+ return "tcp://localhost:" + port;
+ }
+
+ public int port() {
+ return port;
+ }
+
+ public int websocketPort() {
+ if (websocketPort < 1) {
+ throw new IllegalStateException("The fixture was not started with WebSocket support");
+ }
+ return websocketPort;
+ }
+
+ public MqttTestListener listener() {
+ return listener;
+ }
+
+ private static String uniqueClientId() {
+ return "mqtt-" + UUID.randomUUID().toString().substring(0, 16);
+ }
+
+ private static int awaitBoundPort(IntSupplier portSupplier, String transport) throws IOException {
+ long deadline = System.nanoTime() + PORT_BIND_TIMEOUT.toNanos();
+ do {
+ int boundPort = portSupplier.getAsInt();
+ if (boundPort > 0) {
+ return boundPort;
+ }
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while awaiting Moquette " + transport + " port", e);
+ }
+ } while (System.nanoTime() < deadline);
+ throw new IOException("Timed out awaiting Moquette " + transport + " port");
+ }
+
+ @SuppressWarnings("unchecked")
+ private static int readTransportPort(Server broker, String transportName) {
+ try {
+ Field acceptorField = Server.class.getDeclaredField("acceptor");
+ acceptorField.setAccessible(true);
+ Object acceptor = acceptorField.get(broker);
+ Field portsField = acceptor.getClass().getDeclaredField("ports");
+ portsField.setAccessible(true);
+ Map ports = (Map) portsField.get(acceptor);
+ return ports.getOrDefault(transportName, -1);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("Could not read Moquette's bound " + transportName + " port", e);
+ }
+ }
+
+ @Override
+ public void close() throws MqttException {
+ Throwable failure = null;
+ for (IMqttClient client : clients) {
+ try {
+ if (client.isConnected()) {
+ client.disconnect();
+ }
+ } catch (Throwable error) {
+ failure = appendFailure(failure, error);
+ } finally {
+ try {
+ client.close();
+ } catch (Throwable error) {
+ failure = appendFailure(failure, error);
+ }
+ }
+ }
+ try {
+ broker.stopServer();
+ } catch (Throwable error) {
+ failure = appendFailure(failure, error);
+ }
+ try {
+ listener.assertNoSessionLoopError();
+ } catch (Throwable error) {
+ failure = appendFailure(failure, error);
+ }
+ if (failure instanceof MqttException mqttException) {
+ throw mqttException;
+ }
+ if (failure instanceof RuntimeException runtimeException) {
+ throw runtimeException;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ if (failure != null) {
+ throw new AssertionError("Could not close MQTT broker test fixture", failure);
+ }
+ }
+
+ private static Throwable appendFailure(Throwable failure, Throwable nextFailure) {
+ if (failure == null) {
+ return nextFailure;
+ }
+ failure.addSuppressed(nextFailure);
+ return failure;
+ }
+}
diff --git a/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttClientConfigurationTest.java b/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttClientConfigurationTest.java
new file mode 100644
index 000000000..15289d8b6
--- /dev/null
+++ b/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttClientConfigurationTest.java
@@ -0,0 +1,152 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.common.mqttcore;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener.MqttEvent;
+import org.eclipse.paho.client.mqttv3.IMqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+import org.junit.Test;
+
+import io.moquette.broker.config.FluentConfig;
+
+public class MqttClientConfigurationTest {
+ private static final String USERNAME = "testuser";
+ private static final String PASSWORD = "passwd";
+ private static final String PASSWORD_HASH = "0d6be69b264717f2dd33652e212b173104b4a647b7c11ae72e9885f11cd312fb";
+ private final MqttClientConfiguration configuration = new MqttClientConfiguration();
+
+ @Test
+ public void connectsAnonymouslyOverTcp() throws Exception {
+ try (MqttBrokerTestSupport fixture = MqttBrokerTestSupport.start()) {
+ IMqttClient client = fixture.trackClient(configuration.mqttClient(uniqueClientId(), "localhost", fixture.port(), "tcp", configuration.mqttConnectOptions("", "")));
+ assertTrue(client.isConnected());
+ }
+ }
+
+ @Test
+ public void acceptsValidAndRejectsInvalidTcpCredentials() throws Exception {
+ Path passwordFile = Files.createTempFile("moquette-passwords-", ".conf");
+ Files.writeString(passwordFile, USERNAME + ":" + PASSWORD_HASH + System.lineSeparator(), StandardCharsets.UTF_8);
+ try (MqttBrokerTestSupport fixture = MqttBrokerTestSupport.start(new FluentConfig().host("localhost").port(0).disallowAnonymous()
+ .passwordFile(passwordFile.toAbsolutePath().toString()).disablePersistence().disableTelemetry().build())) {
+ IMqttClient validClient = fixture.trackClient(
+ configuration.mqttClient(uniqueClientId(), "localhost", fixture.port(), "tcp", configuration.mqttConnectOptions(USERNAME, PASSWORD)));
+ assertTrue(validClient.isConnected());
+
+ MqttConnectOptions invalidOptions = configuration.mqttConnectOptions(USERNAME, "wrong-password");
+ invalidOptions.setAutomaticReconnect(false);
+ MqttException error = assertThrows(MqttException.class,
+ () -> configuration.mqttClient(uniqueClientId(), "localhost", fixture.port(), "tcp", invalidOptions));
+ assertEquals(MqttException.REASON_CODE_FAILED_AUTHENTICATION, error.getReasonCode());
+ } finally {
+ Files.deleteIfExists(passwordFile);
+ }
+ }
+
+ @Test
+ public void connectsAnonymouslyOverWebSocket() throws Exception {
+ try (MqttBrokerTestSupport fixture = MqttBrokerTestSupport.startWithWebSocket()) {
+ IMqttClient client = fixture.trackClient(configuration.mqttClient(uniqueClientId(), "localhost", fixture.websocketPort(), "ws", configuration.mqttConnectOptions("", "")));
+ assertTrue(client.isConnected());
+ }
+ }
+
+ @Test
+ public void publishesAndAwaitsOneHundredUtf8Events() throws Exception {
+ try (MqttBrokerTestSupport fixture = MqttBrokerTestSupport.start()) {
+ IMqttClient client = fixture.connectClient();
+ for (int i = 0; i < 100; i++) {
+ client.publish("stress/events/" + i, new MqttMessage(("payload-ä-" + i).getBytes(StandardCharsets.UTF_8)));
+ }
+ for (int i = 0; i < 100; i++) {
+ MqttEvent event = fixture.listener().awaitEvent("stress/events/" + i);
+ assertEquals("payload-ä-" + i, event.payload());
+ }
+ }
+ }
+
+ @Test
+ public void publishesConcurrentlyFromMultipleClients() throws Exception {
+ ExecutorService publishers = Executors.newFixedThreadPool(4);
+ try (MqttBrokerTestSupport fixture = MqttBrokerTestSupport.start()) {
+ Set expectedEvents = new HashSet<>();
+ Set> publishes = new HashSet<>();
+ for (int clientIndex = 0; clientIndex < 4; clientIndex++) {
+ IMqttClient client = fixture.connectClient();
+ for (int messageIndex = 0; messageIndex < 25; messageIndex++) {
+ String topic = "concurrent/" + clientIndex + "/" + messageIndex;
+ String payload = "payload-ä-" + clientIndex + "-" + messageIndex;
+ expectedEvents.add(topic + "\n" + payload);
+ publishes.add(publishers.submit(() -> {
+ client.publish(topic, new MqttMessage(payload.getBytes(StandardCharsets.UTF_8)));
+ return null;
+ }));
+ }
+ }
+ for (Future> publish : publishes) {
+ publish.get();
+ }
+ Set actualEvents = new HashSet<>();
+ for (int i = 0; i < expectedEvents.size(); i++) {
+ MqttEvent event = fixture.listener().awaitNextEvent();
+ actualEvents.add(event.topic() + "\n" + event.payload());
+ }
+ assertEquals(expectedEvents, actualEvents);
+ } finally {
+ publishers.shutdownNow();
+ }
+ }
+
+ @Test
+ public void reportsSessionLoopErrorsAfterBrokerShutdown() throws Exception {
+ MqttBrokerTestSupport fixture = MqttBrokerTestSupport.start();
+ fixture.listener().onSessionLoopError(new IllegalStateException("session-loop-failure"));
+
+ AssertionError error = assertThrows(AssertionError.class, fixture::close);
+
+ assertEquals("session-loop-failure", error.getCause().getMessage());
+ }
+
+ private static String uniqueClientId() {
+ return "core-" + UUID.randomUUID().toString().substring(0, 16);
+ }
+
+}
diff --git a/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttEventPublisherTest.java b/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttEventPublisherTest.java
new file mode 100644
index 000000000..5ca00c927
--- /dev/null
+++ b/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttEventPublisherTest.java
@@ -0,0 +1,65 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.common.mqttcore;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.nio.charset.StandardCharsets;
+
+import org.eclipse.paho.client.mqttv3.IMqttClient;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.slf4j.Logger;
+
+public class MqttEventPublisherTest {
+ @Test
+ public void publishesPayloadAsUtf8() throws Exception {
+ IMqttClient client = mock(IMqttClient.class);
+ Logger logger = mock(Logger.class);
+ ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MqttMessage.class);
+
+ MqttEventPublisher.publish(client, "topic", "Grüße", logger);
+
+ verify(client).publish(eq("topic"), messageCaptor.capture());
+ assertEquals("Grüße", new String(messageCaptor.getValue().getPayload(), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void handlesPublishFailuresConsistently() throws Exception {
+ IMqttClient client = mock(IMqttClient.class);
+ Logger logger = mock(Logger.class);
+ MqttException publishError = new MqttException(MqttException.REASON_CODE_CLIENT_NOT_CONNECTED);
+ org.mockito.Mockito.doThrow(publishError).when(client).publish(eq("topic"), org.mockito.ArgumentMatchers.any(MqttMessage.class));
+
+ MqttEventPublisher.publish(client, "topic", "payload", logger);
+
+ verify(logger).error("Could not send MQTT message to topic 'topic'", publishError);
+ }
+}
diff --git a/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/listener/MqttTestListener.java b/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/listener/MqttTestListener.java
new file mode 100644
index 000000000..abb305e2c
--- /dev/null
+++ b/basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/listener/MqttTestListener.java
@@ -0,0 +1,156 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.common.mqttcore.listener;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import io.moquette.interception.InterceptHandler;
+import io.moquette.interception.messages.InterceptAcknowledgedMessage;
+import io.moquette.interception.messages.InterceptConnectMessage;
+import io.moquette.interception.messages.InterceptConnectionLostMessage;
+import io.moquette.interception.messages.InterceptDisconnectMessage;
+import io.moquette.interception.messages.InterceptPublishMessage;
+import io.moquette.interception.messages.InterceptSubscribeMessage;
+import io.moquette.interception.messages.InterceptUnsubscribeMessage;
+
+/**
+ * Thread-safe embedded-broker listener for MQTT feature tests.
+ */
+public class MqttTestListener implements InterceptHandler {
+ private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10);
+ private static final int EVENT_QUEUE_CAPACITY = 1024;
+ private final BlockingQueue events = new ArrayBlockingQueue<>(EVENT_QUEUE_CAPACITY);
+ private final AtomicReference sessionLoopError = new AtomicReference<>();
+
+ @Override
+ public String getID() {
+ return MqttTestListener.class.getName();
+ }
+
+ @Override
+ public Class>[] getInterceptedMessageTypes() {
+ return InterceptHandler.ALL_MESSAGE_TYPES;
+ }
+
+ @Override
+ public void onConnect(InterceptConnectMessage message) {
+ }
+
+ @Override
+ public void onConnectionLost(InterceptConnectionLostMessage message) {
+ }
+
+ @Override
+ public void onDisconnect(InterceptDisconnectMessage message) {
+ }
+
+ @Override
+ public void onMessageAcknowledged(InterceptAcknowledgedMessage message) {
+ }
+
+ @Override
+ public void onPublish(InterceptPublishMessage message) {
+ MqttEvent event = new MqttEvent(message.getTopicName(), message.getPayload().toString(StandardCharsets.UTF_8));
+ if (!events.offer(event)) {
+ sessionLoopError.compareAndSet(null, new AssertionError("MQTT test event queue exceeded its capacity of " + EVENT_QUEUE_CAPACITY));
+ }
+ }
+
+ @Override
+ public void onSubscribe(InterceptSubscribeMessage message) {
+ }
+
+ @Override
+ public void onUnsubscribe(InterceptUnsubscribeMessage message) {
+ }
+
+ @Override
+ public void onSessionLoopError(Throwable error) {
+ sessionLoopError.compareAndSet(null, error);
+ }
+
+ public MqttEvent awaitEvent(String expectedTopic) {
+ return awaitEvent(expectedTopic, DEFAULT_TIMEOUT);
+ }
+
+ public MqttEvent awaitEvent(String expectedTopic, Duration timeout) {
+ long deadline = System.nanoTime() + timeout.toNanos();
+ try {
+ while (System.nanoTime() < deadline) {
+ assertNoSessionLoopError();
+ long remaining = deadline - System.nanoTime();
+ MqttEvent event = events.poll(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(100)), TimeUnit.NANOSECONDS);
+ if (event != null && expectedTopic.equals(event.topic())) {
+ assertNoSessionLoopError();
+ return event;
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError("Interrupted while awaiting MQTT topic '" + expectedTopic + "'", e);
+ }
+ assertNoSessionLoopError();
+ throw new AssertionError("Timed out awaiting MQTT topic '" + expectedTopic + "'");
+ }
+
+ public MqttEvent awaitNextEvent() {
+ return awaitNextEvent(DEFAULT_TIMEOUT);
+ }
+
+ public MqttEvent awaitNextEvent(Duration timeout) {
+ long deadline = System.nanoTime() + timeout.toNanos();
+ try {
+ while (System.nanoTime() < deadline) {
+ assertNoSessionLoopError();
+ long remaining = deadline - System.nanoTime();
+ MqttEvent event = events.poll(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(100)), TimeUnit.NANOSECONDS);
+ if (event != null) {
+ assertNoSessionLoopError();
+ return event;
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError("Interrupted while awaiting an MQTT event", e);
+ }
+ assertNoSessionLoopError();
+ throw new AssertionError("Timed out awaiting an MQTT event");
+ }
+
+ public void assertNoSessionLoopError() {
+ Throwable error = sessionLoopError.get();
+ if (error != null) {
+ throw new AssertionError("Moquette reported an asynchronous session-loop error", error);
+ }
+ }
+
+ public record MqttEvent(String topic, String payload) {
+ }
+}
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java
index 9ae372996..f1be85a87 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java
@@ -327,6 +327,11 @@ public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
}
+ @Override
+ public String getName() {
+ return decorated.getName();
+ }
+
private List getIdAsList(String id) {
return new ArrayList<>(Arrays.asList(id));
}
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/Readme.md b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/Readme.md
index fd810ae9c..636ef45a2 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/Readme.md
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/Readme.md
@@ -8,9 +8,14 @@ This feature provides hierarchical MQTT eventing for a multitude of events:
| Submodel Deleted | sm-repository/$repoId/submodels/deleted| Deleted SM JSON|
| SubmodelElement Created | sm-repository/$repoId/submodels/$submodelIdBase64URLEncoded/submodelElements/$idShortPath/created | Created SubmodelElement JSON |
| SubmodelElement Updated | sm-repository/$repoId/submodels/$submodelIdBase64URLEncoded/submodelElements/$idShortPath/updated | Updated SubmodelElement JSON |
+| SubmodelElement Value Updated | sm-repository/$repoId/submodels/$submodelIdBase64URLEncoded/submodelElements/$idShortPath/value/updated | Updated SubmodelElement JSON |
| SubmodelElement Deleted | sm-repository/$repoId/submodels/$submodelIdBase64URLEncoded/submodelElements/$idShortPath/deleted | Deleted SubmodelElement JSON |
| SubmodelElements Patched | sm-repository/$repoId/submodels/$submodelIdBase64URLEncoded/submodelElements/patched | Patched SubmodelElements JSON |
| FileValue Updated | sm-repository/$repoId/submodels/$submodelIdBase64URLEncoded/submodelElements/$idShortPath/attachment/updated | Updated SubmodelElement JSON |
| FileValue Deleted | sm-repository/$repoId/submodels/$submodelIdBase64URLEncoded/submodelElements/$idShortPath/attachment/deleted | Deleted SubmodelElement JSON |
Per default, the SubmodelElement topic payloads include the SubmodelElement's value. If this is not desired, the SubmodelElement can be annotated with a Qualifier of type *emptyValueUpdateEvent* and value *true*
+
+Submodel identifier segments use unpadded UTF-8 Base64URL encoding. Repository names and idShort paths are not encoded.
+
+> **Breaking topic migration:** `$value` PATCH events are published only on `value/updated`; `updated` is reserved for full element replacement. Existing subscribers must update their subscriptions.
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/pom.xml b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/pom.xml
index 188179ec1..c6c84f71b 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/pom.xml
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/pom.xml
@@ -18,6 +18,13 @@
org.eclipse.digitaltwin.basyx
basyx.mqttcore
+
+ org.eclipse.digitaltwin.basyx
+ basyx.mqttcore
+ ${revision}
+ tests
+ test
+
org.eclipse.digitaltwin.basyx
basyx.submodelrepository-core
@@ -47,4 +54,4 @@
basyx.submodelservice-backend-inmemory
-
\ No newline at end of file
+
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java
index 63d5020dc..8a7a64143 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java
@@ -32,6 +32,7 @@
import org.eclipse.digitaltwin.aas4j.v3.model.OperationVariable;
import org.eclipse.digitaltwin.aas4j.v3.model.Submodel;
import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttEventPublisher;
import org.eclipse.digitaltwin.basyx.common.mqttcore.serializer.SubmodelElementSerializer;
import org.eclipse.digitaltwin.basyx.common.mqttcore.serializer.SubmodelSerializer;
import org.eclipse.digitaltwin.basyx.core.exceptions.CollidingIdentifierException;
@@ -39,12 +40,10 @@
import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult;
import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo;
import org.eclipse.digitaltwin.basyx.submodelrepository.SubmodelRepository;
+import org.eclipse.digitaltwin.basyx.submodelservice.pathparsing.SubmodelElementIdShortHelper;
import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue;
import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelValueOnly;
import org.eclipse.paho.client.mqttv3.IMqttClient;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttMessage;
-import org.eclipse.paho.client.mqttv3.MqttPersistenceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -121,7 +120,7 @@ public SubmodelElementValue getSubmodelElementValue(String submodelId, String sm
public void setSubmodelElementValue(String submodelId, String idShortPath, SubmodelElementValue value) throws ElementDoesNotExistException {
decorated.setSubmodelElementValue(submodelId, idShortPath, value);
SubmodelElement submodelElement = decorated.getSubmodelElement(submodelId, idShortPath);
- submodelElementUpdated(submodelElement, getName(), submodelId, idShortPath);
+ submodelElementValueUpdated(submodelElement, getName(), submodelId, idShortPath);
}
@Override
@@ -134,8 +133,10 @@ public void createSubmodelElement(String submodelId, SubmodelElement smElement)
@Override
public void createSubmodelElement(String submodelId, String idShortPath, SubmodelElement smElement) throws ElementDoesNotExistException {
decorated.createSubmodelElement(submodelId, idShortPath, smElement);
- SubmodelElement submodelElement = decorated.getSubmodelElement(submodelId, idShortPath);
- submodelElementCreated(submodelElement, getName(), submodelId, idShortPath);
+ SubmodelElement parent = decorated.getSubmodelElement(submodelId, idShortPath);
+ String createdElementPath = SubmodelElementIdShortHelper.buildChildIdShortPath(idShortPath, parent, smElement);
+ SubmodelElement createdElement = decorated.getSubmodelElement(submodelId, createdElementPath);
+ submodelElementCreated(createdElement, getName(), submodelId, createdElementPath);
}
@Override
@@ -228,6 +229,10 @@ private void submodelElementCreated(SubmodelElement submodelElement, String repo
sendMqttMessage(topicFactory.createCreateSubmodelElementTopic(repoId, submodelId, submodelElementId), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
}
+ private void submodelElementValueUpdated(SubmodelElement submodelElement, String repoId, String submodelId, String submodelElementId) {
+ sendMqttMessage(topicFactory.createUpdateSubmodelElementValueTopic(repoId, submodelId, submodelElementId), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
+ }
+
private void submodelElementUpdated(SubmodelElement submodelElement, String repoId, String submodelId, String submodelElementId) {
sendMqttMessage(topicFactory.createUpdateSubmodelElementTopic(repoId, submodelId, submodelElementId), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
}
@@ -257,24 +262,7 @@ private void fileValueUpdated(SubmodelElement submodelElement, String repoId, St
* the actual message
*/
private void sendMqttMessage(String topic, String payload) {
- MqttMessage msg = createMqttMessage(payload);
-
- try {
- logger.debug("Send MQTT message to " + topic + ": " + payload);
- mqttClient.publish(topic, msg);
- } catch (MqttPersistenceException e) {
- logger.error("Could not persist mqtt message", e);
- } catch (MqttException e) {
- logger.error("Could not send mqtt message", e);
- }
- }
-
- private MqttMessage createMqttMessage(String payload) {
- if (payload == null) {
- return new MqttMessage();
- } else {
- return new MqttMessage(payload.getBytes());
- }
+ MqttEventPublisher.publish(mqttClient, topic, payload, logger);
}
}
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryConfiguration.java b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryConfiguration.java
index 67cc6563f..afd7cdca9 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryConfiguration.java
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryConfiguration.java
@@ -25,16 +25,8 @@
package org.eclipse.digitaltwin.basyx.submodelrepository.feature.mqtt;
-import org.eclipse.paho.client.mqttv3.IMqttClient;
-import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
-import org.springframework.beans.factory.annotation.Value;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttClientConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
@@ -43,39 +35,5 @@
*/
@ConditionalOnExpression("#{${" + MqttSubmodelRepositoryFeature.FEATURENAME + ".enabled:false} or ${basyx.feature.mqtt.enabled:false}}")
@Configuration
-public class MqttSubmodelRepositoryConfiguration {
-
- @ConditionalOnMissingBean
- @Bean
- public IMqttClient mqttClient(
- @Value("${mqtt.clientId}") String clientId,
- @Value("${mqtt.hostname}") String hostname,
- @Value("${mqtt.port}") int port,
- @Value("${mqtt.protocol:tcp}") String protocol,
- MqttConnectOptions mqttConnectOptions) throws MqttException {
- IMqttClient mqttClient = new MqttClient(protocol + "://" + hostname + ":" + port, clientId,
- new MemoryPersistence());
-
- mqttClient.connect(mqttConnectOptions);
-
- return mqttClient;
- }
-
- @ConditionalOnMissingBean
- @Bean
- @ConfigurationProperties(prefix = "mqtt")
- public MqttConnectOptions mqttConnectOptions(
- @Value("${mqtt.username:}") String username,
- @Value("${mqtt.password:}") String password) {
- MqttConnectOptions mqttConnectOptions = new MqttConnectOptions();
- mqttConnectOptions.setAutomaticReconnect(true);
-
- if (username.isBlank() || password.isBlank())
- return mqttConnectOptions;
-
- mqttConnectOptions.setUserName(username);
- mqttConnectOptions.setPassword(password.toCharArray());
-
- return mqttConnectOptions;
- }
+public class MqttSubmodelRepositoryConfiguration extends MqttClientConfiguration {
}
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryTopicFactory.java b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryTopicFactory.java
index 14ab1f0e5..ed4e8a6c8 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryTopicFactory.java
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryTopicFactory.java
@@ -44,6 +44,7 @@ public class MqttSubmodelRepositoryTopicFactory extends AbstractMqttTopicFactory
private static final String PATCHED = "patched";
private static final String SUBMODELELEMENTS = "submodelElements";
private static final String ATTACHMENT = "attachment";
+ private static final String VALUE = "value";
/**
* @param encoder
@@ -98,6 +99,13 @@ public String createUpdateSubmodelElementTopic(String repoId, String submodelId,
return new StringJoiner("/", "", "").add(SUBMODELREPOSITORY).add(repoId).add(SUBMODELS).add(encodeId(submodelId)).add(SUBMODELELEMENTS).add(submodelElementId).add(UPDATED).toString();
}
+ /**
+ * Creates the hierarchical topic for a submodel element value update.
+ */
+ public String createUpdateSubmodelElementValueTopic(String repoId, String submodelId, String submodelElementId) {
+ return new StringJoiner("/", "", "").add(SUBMODELREPOSITORY).add(repoId).add(SUBMODELS).add(encodeId(submodelId)).add(SUBMODELELEMENTS).add(submodelElementId).add(VALUE).add(UPDATED).toString();
+ }
+
/**
* Creates the hierarchical topic for the delete event of submodelElements
*
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryTopicFactoryTest.java b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryTopicFactoryTest.java
new file mode 100644
index 000000000..7ed29e07b
--- /dev/null
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryTopicFactoryTest.java
@@ -0,0 +1,45 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.submodelrepository.feature.mqtt;
+
+import static org.junit.Assert.assertEquals;
+
+import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
+import org.junit.Test;
+
+public class MqttSubmodelRepositoryTopicFactoryTest {
+ private static final String SUBMODEL_ID = "https://example.org/submodels/ä?x=1";
+ private static final String ENCODED_SUBMODEL_ID = "aHR0cHM6Ly9leGFtcGxlLm9yZy9zdWJtb2RlbHMvw6Q_eD0x";
+
+ private final MqttSubmodelRepositoryTopicFactory topicFactory = new MqttSubmodelRepositoryTopicFactory(new Base64URLEncoder());
+
+ @Test
+ public void distinguishesStructuralAndValueUpdates() {
+ assertEquals("sm-repository/custom-repo/submodels/" + ENCODED_SUBMODEL_ID + "/submodelElements/property/updated",
+ topicFactory.createUpdateSubmodelElementTopic("custom-repo", SUBMODEL_ID, "property"));
+ assertEquals("sm-repository/custom-repo/submodels/" + ENCODED_SUBMODEL_ID + "/submodelElements/property/value/updated",
+ topicFactory.createUpdateSubmodelElementValueTopic("custom-repo", SUBMODEL_ID, "property"));
+ }
+}
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttTestListener.java b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttTestListener.java
deleted file mode 100644
index ce996123b..000000000
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttTestListener.java
+++ /dev/null
@@ -1,96 +0,0 @@
-/*******************************************************************************
- * Copyright (C) 2021 the Eclipse BaSyx Authors
- *
- * Permission is hereby granted, free of charge, to any person obtaining
- * a copy of this software and associated documentation files (the
- * "Software"), to deal in the Software without restriction, including
- * without limitation the rights to use, copy, modify, merge, publish,
- * distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to
- * the following conditions:
- *
- * The above copyright notice and this permission notice shall be
- * included in all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
- * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
- * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
- * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
- * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- *
- * SPDX-License-Identifier: MIT
- ******************************************************************************/
-package org.eclipse.digitaltwin.basyx.submodelrepository.feature.mqtt;
-
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-
-import io.moquette.interception.InterceptHandler;
-import io.moquette.interception.messages.InterceptAcknowledgedMessage;
-import io.moquette.interception.messages.InterceptConnectMessage;
-import io.moquette.interception.messages.InterceptConnectionLostMessage;
-import io.moquette.interception.messages.InterceptDisconnectMessage;
-import io.moquette.interception.messages.InterceptPublishMessage;
-import io.moquette.interception.messages.InterceptSubscribeMessage;
-import io.moquette.interception.messages.InterceptUnsubscribeMessage;
-
-/**
- * Very simple MQTT broker listener for testing API events. Stores the last
- * received event and makes its topic and payload available for reading.
- *
- * @author espen
- *
- */
-public class MqttTestListener implements InterceptHandler {
- // Topic and payload of the most recent event
- public String lastTopic;
- public String lastPayload;
- private ArrayList topics = new ArrayList<>();
-
- @Override
- public String getID() {
- return null;
- }
-
- @Override
- public Class>[] getInterceptedMessageTypes() {
- return null;
- }
-
- @Override
- public void onConnect(InterceptConnectMessage arg0) {
- }
-
- @Override
- public void onConnectionLost(InterceptConnectionLostMessage arg0) {
- }
-
- @Override
- public void onDisconnect(InterceptDisconnectMessage arg0) {
- }
-
- @Override
- public void onMessageAcknowledged(InterceptAcknowledgedMessage arg0) {
- }
-
- @Override
- public synchronized void onPublish(InterceptPublishMessage msg) {
- topics.add(msg.getTopicName());
- lastTopic = msg.getTopicName();
- lastPayload = msg.getPayload().toString(StandardCharsets.UTF_8);
- }
-
- @Override
- public void onSubscribe(InterceptSubscribeMessage arg0) {
- }
-
- @Override
- public void onUnsubscribe(InterceptUnsubscribeMessage arg0) {
- }
-
- public ArrayList getTopics() {
- return topics;
- }
-}
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/TestMqttSubmodelObserver.java b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/TestMqttSubmodelObserver.java
index 963f20d09..eabcae1d6 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/TestMqttSubmodelObserver.java
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/TestMqttSubmodelObserver.java
@@ -27,7 +27,6 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.FileNotFoundException;
@@ -48,7 +47,12 @@
import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultProperty;
import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultQualifier;
import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultSubmodel;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultSubmodelElementCollection;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultSubmodelElementList;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttBrokerTestSupport;
import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener.MqttEvent;
import org.eclipse.digitaltwin.basyx.common.mqttcore.serializer.SubmodelElementSerializer;
import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException;
import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata;
@@ -60,11 +64,8 @@
import org.eclipse.digitaltwin.basyx.submodelservice.InMemorySubmodelBackend;
import org.eclipse.digitaltwin.basyx.submodelservice.value.PropertyValue;
import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue;
-import org.eclipse.paho.client.mqttv3.IMqttClient;
import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttSecurityException;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -72,17 +73,12 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
-import io.moquette.broker.Server;
-import io.moquette.broker.config.ClasspathResourceLoader;
-import io.moquette.broker.config.IConfig;
-import io.moquette.broker.config.IResourceLoader;
-import io.moquette.broker.config.ResourceLoaderConfig;
/**
* Tests events for submodels and submodelElements
*/
public class TestMqttSubmodelObserver {
- private static Server mqttBroker;
+ private static MqttBrokerTestSupport mqttBroker;
private static MqttClient mqttClient;
private static MqttTestListener listener;
private static MqttSubmodelRepositoryTopicFactory topicFactory = new MqttSubmodelRepositoryTopicFactory(new Base64URLEncoder());
@@ -97,19 +93,16 @@ public class TestMqttSubmodelObserver {
@BeforeClass
public static void setUpClass() throws MqttException, IOException {
- mqttBroker = startBroker();
-
- listener = configureInterceptListener(mqttBroker);
-
- mqttClient = createAndConnectClient();
+ mqttBroker = MqttBrokerTestSupport.start();
+ listener = mqttBroker.listener();
+ mqttClient = mqttBroker.connectClient();
submodelRepository = createMqttSubmodelRepository(mqttClient);
}
@AfterClass
- public static void tearDownClass() {
- mqttBroker.removeInterceptHandler(listener);
- mqttBroker.stopServer();
+ public static void tearDownClass() throws MqttException {
+ mqttBroker.close();
}
@Test
@@ -117,8 +110,8 @@ public void createSubmodelEvent() throws DeserializationException {
Submodel submodel = createSubmodelDummy("createSubmodelEventId");
submodelRepository.createSubmodel(submodel);
- assertEquals(topicFactory.createCreateSubmodelTopic(submodelRepository.getName()), listener.lastTopic);
- assertEquals(submodel, deserializeSubmodelPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelTopic(submodelRepository.getName()));
+ assertEquals(submodel, deserializeSubmodelPayload(event.payload()));
}
@Test
@@ -128,8 +121,8 @@ public void updateSubmodelEvent() throws DeserializationException {
submodel.setSubmodelElements(Arrays.asList(createSubmodelElementDummy("submodelElementForUpdateSubmodelEventId")));
submodelRepository.updateSubmodel(submodel.getId(), submodel);
- assertEquals(topicFactory.createUpdateSubmodelTopic(submodelRepository.getName()), listener.lastTopic);
- assertEquals(submodel, deserializeSubmodelPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createUpdateSubmodelTopic(submodelRepository.getName()));
+ assertEquals(submodel, deserializeSubmodelPayload(event.payload()));
}
@Test
@@ -138,8 +131,8 @@ public void deleteSubmodelEvent() throws DeserializationException {
submodelRepository.createSubmodel(submodel);
submodelRepository.deleteSubmodel(submodel.getId());
- assertEquals(topicFactory.createDeleteSubmodelTopic(submodelRepository.getName()), listener.lastTopic);
- assertEquals(submodel, deserializeSubmodelPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createDeleteSubmodelTopic(submodelRepository.getName()));
+ assertEquals(submodel, deserializeSubmodelPayload(event.payload()));
}
@Test
@@ -149,8 +142,37 @@ public void createSubmodelElementEvent() throws DeserializationException {
SubmodelElement submodelElement = createSubmodelElementDummy("createSubmodelElementEventId");
submodelRepository.createSubmodelElement(submodel.getId(), submodelElement);
- assertEquals(topicFactory.createCreateSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()), listener.lastTopic);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
+ }
+
+ @Test
+ public void createNestedSubmodelElementInCollectionEvent() throws DeserializationException {
+ String collectionIdShort = "nestedCollection";
+ Submodel submodel = new DefaultSubmodel.Builder().id("nestedCollectionSubmodel").submodelElements(new DefaultSubmodelElementCollection.Builder().idShort(collectionIdShort).build()).build();
+ submodelRepository.createSubmodel(submodel);
+ SubmodelElement child = createSubmodelElementDummy("nestedCollectionChild");
+ String childPath = collectionIdShort + "." + child.getIdShort();
+
+ submodelRepository.createSubmodelElement(submodel.getId(), collectionIdShort, child);
+
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), childPath));
+ assertEquals(child, deserializeSubmodelElementPayload(event.payload()));
+ }
+
+ @Test
+ public void createNestedSubmodelElementInListEvent() throws DeserializationException {
+ String listIdShort = "nestedList";
+ SubmodelElement existingElement = createSubmodelElementDummy("existingListElement");
+ Submodel submodel = new DefaultSubmodel.Builder().id("nestedListSubmodel").submodelElements(new DefaultSubmodelElementList.Builder().idShort(listIdShort).value(existingElement).build()).build();
+ submodelRepository.createSubmodel(submodel);
+ SubmodelElement child = createSubmodelElementDummy("nestedListChild");
+ String childPath = listIdShort + "[1]";
+
+ submodelRepository.createSubmodelElement(submodel.getId(), listIdShort, child);
+
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), childPath));
+ assertEquals(child, deserializeSubmodelElementPayload(event.payload()));
}
@Test
@@ -162,8 +184,20 @@ public void updateSubmodelElementEvent() throws DeserializationException {
SubmodelElementValue value = new PropertyValue("updatedValue");
submodelRepository.setSubmodelElementValue(submodel.getId(), submodelElement.getIdShort(), value);
- assertEquals(topicFactory.createUpdateSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()), listener.lastTopic);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createUpdateSubmodelElementValueTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
+ }
+
+ @Test
+ public void replaceSubmodelElementEvent() throws DeserializationException {
+ Submodel submodel = createSubmodelDummyWithSubmodelElement("replaceSubmodelForElementEventId", "replaceSubmodelElementEventId");
+ submodelRepository.createSubmodel(submodel);
+ SubmodelElement replacement = createSubmodelElementDummy("replaceSubmodelElementEventId");
+
+ submodelRepository.updateSubmodelElement(submodel.getId(), replacement.getIdShort(), replacement);
+
+ MqttEvent event = listener.awaitEvent(topicFactory.createUpdateSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), replacement.getIdShort()));
+ assertEquals(replacement, deserializeSubmodelElementPayload(event.payload()));
}
@Test
@@ -175,8 +209,8 @@ public void deleteSubmodelElementEvent() throws DeserializationException {
submodelRepository.deleteSubmodelElement(submodel.getId(), submodelElement.getIdShort());
- assertEquals(topicFactory.createDeleteSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()), listener.lastTopic);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createDeleteSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
}
@Test
@@ -188,12 +222,12 @@ public void createSubmodelElementWithoutValueEvent() throws DeserializationExcep
submodelElement.setQualifiers(qualifierList);
submodelRepository.createSubmodelElement(submodel.getId(), submodelElement);
- assertEquals(topicFactory.createCreateSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()), listener.lastTopic);
- assertNotEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelElementTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()));
+ assertNotEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
// remove value for equality check
((Property) submodelElement).setValue(null);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
}
@Test
@@ -210,8 +244,8 @@ public void patchSubmodelElementsEvent() throws DeserializationException, JsonMa
submodelRepository.patchSubmodelElements(submodel.getId(), submodelElements);
- assertEquals(topicFactory.createPatchSubmodelElementsTopic(submodelRepository.getName(), submodel.getId()), listener.lastTopic);
- assertEquals(submodelElements, deserializeSubmodelElementsListPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createPatchSubmodelElementsTopic(submodelRepository.getName(), submodel.getId()));
+ assertEquals(submodelElements, deserializeSubmodelElementsListPayload(event.payload()));
}
@Test
@@ -223,8 +257,8 @@ public void setFileValueEvent() throws DeserializationException, IOException {
submodelRepository.setFileValue(submodel.getId(), submodelElement.getIdShort(), FILE_SUBMODEL_ELEMENT_NAME, "application/octet-stream", getInputStreamOfDummyFile(FILE_SUBMODEL_ELEMENT_CONTENT));
- assertEquals(topicFactory.createUpdateFileValueTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()), listener.lastTopic);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createUpdateFileValueTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
}
@Test
@@ -236,8 +270,8 @@ public void deleteFileValueEvent() throws DeserializationException, IOException
submodelRepository.deleteFileValue(submodel.getId(), submodelElement.getIdShort());
- assertEquals(topicFactory.createDeleteFileValueTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()), listener.lastTopic);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createDeleteFileValueTopic(submodelRepository.getName(), submodel.getId(), submodelElement.getIdShort()));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
}
private List createNoValueQualifierList() {
@@ -317,106 +351,4 @@ private static SubmodelRepository createMqttSubmodelRepository(MqttClient client
return new MqttSubmodelRepositoryFactory(repoFactory, client, new MqttSubmodelRepositoryTopicFactory(new Base64URLEncoder())).create();
}
- private static MqttTestListener configureInterceptListener(Server broker) {
- MqttTestListener testListener = new MqttTestListener();
- broker.addInterceptHandler(testListener);
-
- return testListener;
- }
-
- private static MqttClient createAndConnectClient() throws MqttException, MqttSecurityException {
- MqttClient client = new MqttClient("tcp://localhost:1884", "testClient");
- client.connect();
- return client;
- }
-
- private static Server startBroker() throws IOException {
- Server broker = new Server();
- IResourceLoader classpathLoader = new ClasspathResourceLoader();
-
- IConfig classPathConfig = new ResourceLoaderConfig(classpathLoader);
- broker.startServer(classPathConfig);
-
- return broker;
- }
-
- @Test
- public void checkTCPConnectionWithoutCredentials() throws Exception {
- MqttSubmodelRepositoryConfiguration config = new MqttSubmodelRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("", "");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- // assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkTCPConnectionWitCredentials() throws Exception {
- MqttSubmodelRepositoryConfiguration config = new MqttSubmodelRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "passwd");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkTCPConnectionWitWrongCredentials() throws Exception {
- MqttSubmodelRepositoryConfiguration config = new MqttSubmodelRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "false");
- boolean authentication_failed = false;
- try {
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- } catch (MqttException e) {
- if (MqttException.REASON_CODE_FAILED_AUTHENTICATION == e.getReasonCode()) {
- authentication_failed = true;
- }
- }
- assertTrue(authentication_failed);
- }
-
- @Test
- public void checkWSConnectionWithoutCredentials() throws Exception {
- MqttSubmodelRepositoryConfiguration config = new MqttSubmodelRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("", "");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 8080,
- "ws",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkWSConnectionWitCredentials() throws Exception {
- MqttSubmodelRepositoryConfiguration config = new MqttSubmodelRepositoryConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "passwd");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 8080,
- "ws",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
}
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/resources/config/moquette.conf b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/resources/config/moquette.conf
deleted file mode 100644
index 94e6f3440..000000000
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/resources/config/moquette.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-# Moquette Java Broker configuration file for testing
-
-# Do not use the default 1883 port
-port 1884
-websocket_port 8080
-host 0.0.0.0
-password_file config/password_file.conf
-allow_anonymous true
\ No newline at end of file
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/resources/config/password_file.conf b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/resources/config/password_file.conf
deleted file mode 100644
index e8062773e..000000000
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/resources/config/password_file.conf
+++ /dev/null
@@ -1 +0,0 @@
-testuser:0d6be69b264717f2dd33652e212b173104b4a647b7c11ae72e9885f11cd312fb
\ No newline at end of file
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java
index db6ea53a6..4a1af0018 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java
@@ -243,4 +243,9 @@ public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
}
+ @Override
+ public String getName() {
+ return decorated.getName();
+ }
+
}
diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java
index a5afb007a..40ea64cd2 100644
--- a/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java
+++ b/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java
@@ -189,6 +189,11 @@ public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
}
+ @Override
+ public String getName() {
+ return decorated.getName();
+ }
+
private void indexSM(Submodel submodel) {
try {
JsonNode normalizedSubmodel = IndexNormalizer.toIndexable(submodel);
diff --git a/basyx.submodelrepository/basyx.submodelrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/component/SubmodelRepositoryDecoratorNameTest.java b/basyx.submodelrepository/basyx.submodelrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/component/SubmodelRepositoryDecoratorNameTest.java
new file mode 100644
index 000000000..04c0c930c
--- /dev/null
+++ b/basyx.submodelrepository/basyx.submodelrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/component/SubmodelRepositoryDecoratorNameTest.java
@@ -0,0 +1,75 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.submodelrepository.component;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.eclipse.digitaltwin.basyx.authorization.rbac.RbacPermissionResolver;
+import org.eclipse.digitaltwin.basyx.submodelregistry.client.mapper.AttributeMapper;
+import org.eclipse.digitaltwin.basyx.submodelrepository.SubmodelRepository;
+import org.eclipse.digitaltwin.basyx.submodelrepository.feature.authorization.AuthorizedSubmodelRepository;
+import org.eclipse.digitaltwin.basyx.submodelrepository.feature.registry.integration.RegistryIntegrationSubmodelRepository;
+import org.eclipse.digitaltwin.basyx.submodelrepository.feature.registry.integration.SubmodelRepositoryRegistryLink;
+import org.eclipse.digitaltwin.basyx.submodelrepository.feature.search.SearchSubmodelRepository;
+import org.junit.Test;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+
+public class SubmodelRepositoryDecoratorNameTest {
+ private static final String CONFIGURED_REPOSITORY_NAME = "configured-sm-repo";
+
+ @Test
+ public void everyDecoratorPreservesConfiguredRepositoryName() {
+ SubmodelRepository decorated = mock(SubmodelRepository.class);
+ when(decorated.getName()).thenReturn(CONFIGURED_REPOSITORY_NAME);
+
+ assertEquals(CONFIGURED_REPOSITORY_NAME, new AuthorizedSubmodelRepository(decorated, mock(RbacPermissionResolver.class)).getName());
+ assertEquals(CONFIGURED_REPOSITORY_NAME,
+ new RegistryIntegrationSubmodelRepository(decorated, mock(SubmodelRepositoryRegistryLink.class), mock(AttributeMapper.class)).getName());
+ assertEquals(CONFIGURED_REPOSITORY_NAME, new SearchSubmodelRepository(decorated, mock(ElasticsearchClient.class), "index").getName());
+ }
+
+ @Test
+ public void mixedDecoratorOrderingPreservesConfiguredRepositoryName() {
+ SubmodelRepository decorated = mock(SubmodelRepository.class);
+ when(decorated.getName()).thenReturn(CONFIGURED_REPOSITORY_NAME);
+
+ SubmodelRepository authorizationOutside = new AuthorizedSubmodelRepository(
+ new RegistryIntegrationSubmodelRepository(
+ new SearchSubmodelRepository(decorated, mock(ElasticsearchClient.class), "index"),
+ mock(SubmodelRepositoryRegistryLink.class), mock(AttributeMapper.class)),
+ mock(RbacPermissionResolver.class));
+ SubmodelRepository searchOutside = new SearchSubmodelRepository(
+ new AuthorizedSubmodelRepository(
+ new RegistryIntegrationSubmodelRepository(decorated, mock(SubmodelRepositoryRegistryLink.class), mock(AttributeMapper.class)),
+ mock(RbacPermissionResolver.class)),
+ mock(ElasticsearchClient.class), "index");
+
+ assertEquals(CONFIGURED_REPOSITORY_NAME, authorizationOutside.getName());
+ assertEquals(CONFIGURED_REPOSITORY_NAME, searchOutside.getName());
+ }
+}
diff --git a/basyx.submodelrepository/basyx.submodelrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/component/TestSubmodelRepositoryName.java b/basyx.submodelrepository/basyx.submodelrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/component/TestSubmodelRepositoryName.java
index e2963f981..1539b4685 100644
--- a/basyx.submodelrepository/basyx.submodelrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/component/TestSubmodelRepositoryName.java
+++ b/basyx.submodelrepository/basyx.submodelrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/component/TestSubmodelRepositoryName.java
@@ -26,11 +26,21 @@
package org.eclipse.digitaltwin.basyx.submodelrepository.component;
import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import java.util.Map;
+
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultSubmodel;
import org.eclipse.digitaltwin.basyx.submodelrepository.SubmodelRepository;
+import org.eclipse.paho.client.mqttv3.IMqttClient;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.junit.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.env.SystemEnvironmentPropertySource;
/**
*
@@ -74,6 +84,22 @@ public void getConfiguredRepoName() {
resetRepoNamePropertyAndCloseContext();
}
+ @Test
+ public void environmentStyleRepositoryNameReachesMqttTopic() throws Exception {
+ IMqttClient mqttClient = mock(IMqttClient.class);
+ SpringApplication application = new SpringApplication(SubmodelRepositoryComponent.class);
+ application.addInitializers(context -> {
+ context.getEnvironment().getPropertySources().addFirst(new SystemEnvironmentPropertySource("test-environment",
+ Map.of("BASYX_SMREPO_NAME", CONFIGURED_SM_REPO_NAME, "BASYX_FEATURE_MQTT_ENABLED", "true")));
+ context.getBeanFactory().registerSingleton("mqttTestClient", mqttClient);
+ });
+
+ try (ConfigurableApplicationContext context = application.run()) {
+ context.getBean(SubmodelRepository.class).createSubmodel(new DefaultSubmodel.Builder().id("mqtt-name-test-submodel").build());
+ verify(mqttClient).publish(eq("sm-repository/" + CONFIGURED_SM_REPO_NAME + "/submodels/created"), any(MqttMessage.class));
+ }
+ }
+
private void resetRepoNamePropertyAndCloseContext() {
System.clearProperty(BASYX_SMREPO_NAME_KEY);
diff --git a/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/pathparsing/SubmodelElementIdShortHelper.java b/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/pathparsing/SubmodelElementIdShortHelper.java
index 6b2a05514..c54c3b4c7 100644
--- a/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/pathparsing/SubmodelElementIdShortHelper.java
+++ b/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/pathparsing/SubmodelElementIdShortHelper.java
@@ -25,6 +25,9 @@
package org.eclipse.digitaltwin.basyx.submodelservice.pathparsing;
+import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement;
+import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElementList;
+
public class SubmodelElementIdShortHelper {
/**
@@ -80,6 +83,25 @@ public static boolean isDirectParentASubmodelElementList(String idShortPath) {
return false;
}
+ /**
+ * Builds the path of a child that has just been added to a parent element.
+ *
+ * @param parentIdShortPath
+ * path of the parent element
+ * @param parent
+ * parent element after the child was added
+ * @param child
+ * added child element
+ * @return path of the added child
+ */
+ public static String buildChildIdShortPath(String parentIdShortPath, SubmodelElement parent, SubmodelElement child) {
+ if (parent instanceof SubmodelElementList list) {
+ return parentIdShortPath + "[" + (list.getValue().size() - 1) + "]";
+ }
+
+ return parentIdShortPath + "." + child.getIdShort();
+ }
+
private static boolean hasDot(String idShortPath) {
return idShortPath.contains(".");
}
diff --git a/basyx.submodelservice/basyx.submodelservice-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelElementIdShortHelperTest.java b/basyx.submodelservice/basyx.submodelservice-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelElementIdShortHelperTest.java
new file mode 100644
index 000000000..3d7b93f11
--- /dev/null
+++ b/basyx.submodelservice/basyx.submodelservice-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelElementIdShortHelperTest.java
@@ -0,0 +1,66 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+
+package org.eclipse.digitaltwin.basyx.submodelservice;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.List;
+
+import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultAnnotatedRelationshipElement;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultEntity;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultProperty;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultSubmodelElementCollection;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultSubmodelElementList;
+import org.eclipse.digitaltwin.basyx.submodelservice.pathparsing.SubmodelElementIdShortHelper;
+import org.junit.Test;
+
+public class SubmodelElementIdShortHelperTest {
+ private static final String PARENT_PATH = "root.parent";
+ private static final SubmodelElement CHILD = new DefaultProperty.Builder().idShort("child").build();
+
+ @Test
+ public void buildsCollectionChildPath() {
+ assertEquals("root.parent.child", SubmodelElementIdShortHelper.buildChildIdShortPath(PARENT_PATH, new DefaultSubmodelElementCollection(), CHILD));
+ }
+
+ @Test
+ public void buildsEntityStatementPath() {
+ assertEquals("root.parent.child", SubmodelElementIdShortHelper.buildChildIdShortPath(PARENT_PATH, new DefaultEntity(), CHILD));
+ }
+
+ @Test
+ public void buildsAnnotatedRelationshipElementAnnotationPath() {
+ assertEquals("root.parent.child", SubmodelElementIdShortHelper.buildChildIdShortPath(PARENT_PATH, new DefaultAnnotatedRelationshipElement(), CHILD));
+ }
+
+ @Test
+ public void buildsListChildPathFromLastIndex() {
+ DefaultSubmodelElementList parent = new DefaultSubmodelElementList.Builder().value(List.of(new DefaultProperty(), CHILD)).build();
+
+ assertEquals("root.parent[1]", SubmodelElementIdShortHelper.buildChildIdShortPath(PARENT_PATH, parent, CHILD));
+ }
+}
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/Readme.md b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/Readme.md
new file mode 100644
index 000000000..4637a855a
--- /dev/null
+++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/Readme.md
@@ -0,0 +1,19 @@
+# Submodel Service - MQTT Eventing
+
+This feature provides hierarchical MQTT eventing for submodel-element changes:
+
+| Event | Topic | Payload |
+| --- | --- | --- |
+| SubmodelElement Created | sm-service/submodels/$submodelIdBase64Url/submodelElements/$idShortPath/created | Created SubmodelElement JSON |
+| SubmodelElement Replaced | sm-service/submodels/$submodelIdBase64Url/submodelElements/$idShortPath/updated | Updated SubmodelElement JSON |
+| SubmodelElement Value Updated | sm-service/submodels/$submodelIdBase64Url/submodelElements/$idShortPath/value/updated | Updated SubmodelElement JSON |
+| SubmodelElement Deleted | sm-service/submodels/$submodelIdBase64Url/submodelElements/$idShortPath/deleted | Deleted SubmodelElement JSON |
+| SubmodelElements Patched | sm-service/submodels/$submodelIdBase64Url/submodelElements/patched | Patched SubmodelElements JSON |
+| FileValue Updated | sm-service/submodels/$submodelIdBase64Url/submodelElements/$idShortPath/attachment/updated | Updated SubmodelElement JSON |
+| FileValue Deleted | sm-service/submodels/$submodelIdBase64Url/submodelElements/$idShortPath/attachment/deleted | Deleted SubmodelElement JSON |
+
+Submodel identifier segments use unpadded UTF-8 Base64URL encoding. idShort paths are not encoded.
+
+By default, SubmodelElement payloads include the element value. To omit it, annotate the element with a qualifier of type `emptyValueUpdateEvent` and value `true`.
+
+> **Breaking topic migration:** The canonical topic now contains the encoded submodel ID, and `$value` PATCH events use `value/updated`. Events are not dual-published on legacy topics, so existing subscribers must update their subscriptions.
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/pom.xml b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/pom.xml
index b5e2cef4d..538d4d20d 100644
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/pom.xml
+++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/pom.xml
@@ -18,6 +18,13 @@
org.eclipse.digitaltwin.basyx
basyx.mqttcore
+
+ org.eclipse.digitaltwin.basyx
+ basyx.mqttcore
+ ${revision}
+ tests
+ test
+
org.eclipse.digitaltwin.basyx
basyx.submodelservice-core
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java
index 2a8773c68..503f145b1 100644
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java
+++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java
@@ -32,6 +32,7 @@
import org.eclipse.digitaltwin.aas4j.v3.model.OperationVariable;
import org.eclipse.digitaltwin.aas4j.v3.model.Submodel;
import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttEventPublisher;
import org.eclipse.digitaltwin.basyx.common.mqttcore.serializer.SubmodelElementSerializer;
import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException;
import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException;
@@ -39,11 +40,9 @@
import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult;
import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo;
import org.eclipse.digitaltwin.basyx.submodelservice.SubmodelService;
+import org.eclipse.digitaltwin.basyx.submodelservice.pathparsing.SubmodelElementIdShortHelper;
import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue;
import org.eclipse.paho.client.mqttv3.IMqttClient;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttMessage;
-import org.eclipse.paho.client.mqttv3.MqttPersistenceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -58,11 +57,17 @@ public class MqttSubmodelService implements SubmodelService {
private MqttSubmodelServiceTopicFactory topicFactory;
private SubmodelService decorated;
private IMqttClient mqttClient;
+ private String submodelId;
- public MqttSubmodelService(SubmodelService decorated, IMqttClient mqttClient, MqttSubmodelServiceTopicFactory topicFactory) {
+ public MqttSubmodelService(SubmodelService decorated, IMqttClient mqttClient, MqttSubmodelServiceTopicFactory topicFactory, String submodelId) {
this.topicFactory = topicFactory;
this.decorated = decorated;
this.mqttClient = mqttClient;
+ this.submodelId = submodelId;
+ }
+
+ public MqttSubmodelService(SubmodelService decorated, IMqttClient mqttClient, MqttSubmodelServiceTopicFactory topicFactory) {
+ this(decorated, mqttClient, topicFactory, decorated.getSubmodel().getId());
}
@Override
@@ -89,7 +94,7 @@ public SubmodelElementValue getSubmodelElementValue(String idShortPath) throws E
public void setSubmodelElementValue(String idShortPath, SubmodelElementValue value) throws ElementDoesNotExistException {
decorated.setSubmodelElementValue(idShortPath, value);
SubmodelElement submodelElement = decorated.getSubmodelElement(idShortPath);
- submodelElementUpdated(submodelElement, idShortPath);
+ submodelElementValueUpdated(submodelElement, idShortPath);
}
@Override
@@ -101,19 +106,19 @@ public void createSubmodelElement(SubmodelElement submodelElement) {
@Override
public void createSubmodelElement(String idShortPath, SubmodelElement submodelElement) throws ElementDoesNotExistException {
-
decorated.createSubmodelElement(idShortPath, submodelElement);
-
- SubmodelElement smElement = decorated.getSubmodelElement(submodelElement.getIdShort());
- submodelElementCreated(smElement, idShortPath);
+ SubmodelElement parent = decorated.getSubmodelElement(idShortPath);
+ String createdElementPath = SubmodelElementIdShortHelper.buildChildIdShortPath(idShortPath, parent, submodelElement);
+ SubmodelElement createdElement = decorated.getSubmodelElement(createdElementPath);
+ submodelElementCreated(createdElement, createdElementPath);
}
@Override
public void updateSubmodelElement(String idShortPath, SubmodelElement submodelElement) throws ElementDoesNotExistException {
decorated.updateSubmodelElement(idShortPath, submodelElement);
- SubmodelElement smElement = decorated.getSubmodelElement(submodelElement.getIdShort());
- submodelElementUpdated(smElement, submodelElement.getIdShort());
+ SubmodelElement smElement = decorated.getSubmodelElement(idShortPath);
+ submodelElementUpdated(smElement, idShortPath);
}
@Override
@@ -127,6 +132,7 @@ public void deleteSubmodelElement(String idShortPath) throws ElementDoesNotExist
@Override
public void patchSubmodelElements(List submodelElementList) {
decorated.patchSubmodelElements(submodelElementList);
+ sendMqttMessage(topicFactory.createPatchSubmodelElementsTopic(submodelId), SubmodelElementSerializer.serializeSubmodelElements(submodelElementList));
}
@Override
@@ -147,11 +153,15 @@ public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesN
@Override
public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException {
decorated.setFileValue(idShortPath, fileName, contentType, inputStream);
+ SubmodelElement submodelElement = decorated.getSubmodelElement(idShortPath);
+ sendMqttMessage(topicFactory.createUpdateFileValueTopic(submodelId, idShortPath), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
}
@Override
public void deleteFileValue(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException {
+ SubmodelElement submodelElement = decorated.getSubmodelElement(idShortPath);
decorated.deleteFileValue(idShortPath);
+ sendMqttMessage(topicFactory.createDeleteFileValueTopic(submodelId, idShortPath), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
}
@Override
@@ -160,15 +170,19 @@ public InputStream getFileByFilePath(String filePath) throws FileDoesNotExistExc
}
private void submodelElementCreated(SubmodelElement submodelElement, String idShort) {
- sendMqttMessage(topicFactory.createCreateSubmodelElementTopic(idShort), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
+ sendMqttMessage(topicFactory.createCreateSubmodelElementTopic(submodelId, idShort), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
}
private void submodelElementUpdated(SubmodelElement submodelElement, String idShortPath) {
- sendMqttMessage(topicFactory.createUpdateSubmodelElementTopic(idShortPath), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
+ sendMqttMessage(topicFactory.createUpdateSubmodelElementTopic(submodelId, idShortPath), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
+ }
+
+ private void submodelElementValueUpdated(SubmodelElement submodelElement, String idShortPath) {
+ sendMqttMessage(topicFactory.createUpdateSubmodelElementValueTopic(submodelId, idShortPath), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
}
private void submodelElementDeleted(SubmodelElement submodelElement, String idShort) {
- sendMqttMessage(topicFactory.createDeleteSubmodelElementTopic(idShort), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
+ sendMqttMessage(topicFactory.createDeleteSubmodelElementTopic(submodelId, idShort), SubmodelElementSerializer.serializeSubmodelElement(submodelElement));
}
/**
@@ -180,23 +194,6 @@ private void submodelElementDeleted(SubmodelElement submodelElement, String idSh
* the actual message
*/
private void sendMqttMessage(String topic, String payload) {
- MqttMessage msg = createMqttMessage(payload);
-
- try {
- logger.debug("Send MQTT message to " + topic + ": " + payload);
- mqttClient.publish(topic, msg);
- } catch (MqttPersistenceException e) {
- logger.error("Could not persist mqtt message", e);
- } catch (MqttException e) {
- logger.error("Could not send mqtt message", e);
- }
- }
-
- private MqttMessage createMqttMessage(String payload) {
- if (payload == null) {
- return new MqttMessage();
- } else {
- return new MqttMessage(payload.getBytes());
- }
+ MqttEventPublisher.publish(mqttClient, topic, payload, logger);
}
}
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceConfiguration.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceConfiguration.java
index 946e86d90..7bb4adc3f 100644
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceConfiguration.java
+++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceConfiguration.java
@@ -25,16 +25,8 @@
package org.eclipse.digitaltwin.basyx.submodelservice.feature.mqtt;
-import org.eclipse.paho.client.mqttv3.IMqttClient;
-import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
-import org.springframework.beans.factory.annotation.Value;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttClientConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
@@ -45,39 +37,5 @@
*/
@ConditionalOnExpression("#{${" + MqttSubmodelServiceFeature.FEATURENAME + ".enabled:false} or ${basyx.feature.mqtt.enabled:false}}")
@Configuration
-public class MqttSubmodelServiceConfiguration {
-
- @ConditionalOnMissingBean
- @Bean
- public IMqttClient mqttClient(
- @Value("${mqtt.clientId}") String clientId,
- @Value("${mqtt.hostname}") String hostname,
- @Value("${mqtt.port}") int port,
- @Value("${mqtt.protocol:tcp}") String protocol,
- MqttConnectOptions mqttConnectOptions) throws MqttException {
- IMqttClient mqttClient = new MqttClient(protocol + "://" + hostname + ":" + port, clientId,
- new MemoryPersistence());
-
- mqttClient.connect(mqttConnectOptions);
-
- return mqttClient;
- }
-
- @ConditionalOnMissingBean
- @Bean
- @ConfigurationProperties(prefix = "mqtt")
- public MqttConnectOptions mqttConnectOptions(
- @Value("${mqtt.username:}") String username,
- @Value("${mqtt.password:}") String password) {
- MqttConnectOptions mqttConnectOptions = new MqttConnectOptions();
- mqttConnectOptions.setAutomaticReconnect(true);
-
- if (username.isBlank() || password.isBlank())
- return mqttConnectOptions;
-
- mqttConnectOptions.setUserName(username);
- mqttConnectOptions.setPassword(password.toCharArray());
-
- return mqttConnectOptions;
- }
+public class MqttSubmodelServiceConfiguration extends MqttClientConfiguration {
}
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceFactory.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceFactory.java
index 87f680c46..96f1dd098 100644
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceFactory.java
+++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceFactory.java
@@ -49,11 +49,11 @@ public MqttSubmodelServiceFactory(SubmodelServiceFactory decorated, IMqttClient
@Override
public SubmodelService create(Submodel submodel) {
- return new MqttSubmodelService(decorated.create(submodel), client, topicFactory);
+ return new MqttSubmodelService(decorated.create(submodel), client, topicFactory, submodel.getId());
}
@Override
public SubmodelService create(String submodelId) {
- return new MqttSubmodelService(decorated.create(submodelId), client, topicFactory);
+ return new MqttSubmodelService(decorated.create(submodelId), client, topicFactory, submodelId);
}
}
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceTopicFactory.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceTopicFactory.java
index 31296f4d0..2dcdff471 100644
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceTopicFactory.java
+++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelServiceTopicFactory.java
@@ -42,10 +42,13 @@ public class MqttSubmodelServiceTopicFactory extends AbstractMqttTopicFactory {
private static final String CREATED = "created";
private static final String UPDATED = "updated";
private static final String DELETED = "deleted";
+ private static final String PATCHED = "patched";
private static final String SUBMODEL_ELEMENTS = "submodelElements";
+ private static final String VALUE = "value";
+ private static final String ATTACHMENT = "attachment";
/**
- * Used for encoding the idShort
+ * Used for encoding the submodel identifier
*
* @param encoder
*/
@@ -56,28 +59,54 @@ public MqttSubmodelServiceTopicFactory(Encoder encoder) {
/**
* Creates the hierarchical topic for the create event of submodelElements
*
- * @param idShort
+ * @param submodelId
+ * @param idShortPath
*/
- public String createCreateSubmodelElementTopic(String idShort) {
- return new StringJoiner("/", "", "").add(SUBMODEL_SERVICE).add(SUBMODELS).add(SUBMODEL_ELEMENTS).add(idShort).add(CREATED).toString();
+ public String createCreateSubmodelElementTopic(String submodelId, String idShortPath) {
+ return createElementTopic(submodelId, idShortPath).add(CREATED).toString();
}
/**
* Creates the hierarchical topic for the update event of submodelElements
*
- * @param idShort
+ * @param submodelId
+ * @param idShortPath
*/
- public String createUpdateSubmodelElementTopic(String idShort) {
+ public String createUpdateSubmodelElementTopic(String submodelId, String idShortPath) {
+ return createElementTopic(submodelId, idShortPath).add(UPDATED).toString();
+ }
- return new StringJoiner("/", "", "").add(SUBMODEL_SERVICE).add(SUBMODELS).add(SUBMODEL_ELEMENTS).add(idShort).add(UPDATED).toString();
+ public String createUpdateSubmodelElementValueTopic(String submodelId, String idShortPath) {
+ return createElementTopic(submodelId, idShortPath).add(VALUE).add(UPDATED).toString();
}
/**
* Creates the hierarchical topic for the delete event of submodelElements
*
- * @param idShort
+ * @param submodelId
+ * @param idShortPath
*/
- public String createDeleteSubmodelElementTopic(String idShort) {
- return new StringJoiner("/", "", "").add(SUBMODEL_SERVICE).add(SUBMODELS).add(SUBMODEL_ELEMENTS).add(idShort).add(DELETED).toString();
+ public String createDeleteSubmodelElementTopic(String submodelId, String idShortPath) {
+ return createElementTopic(submodelId, idShortPath).add(DELETED).toString();
+ }
+
+ public String createPatchSubmodelElementsTopic(String submodelId) {
+ return createSubmodelTopic(submodelId).add(SUBMODEL_ELEMENTS).add(PATCHED).toString();
+ }
+
+ public String createUpdateFileValueTopic(String submodelId, String idShortPath) {
+ return createElementTopic(submodelId, idShortPath).add(ATTACHMENT).add(UPDATED).toString();
+ }
+
+ public String createDeleteFileValueTopic(String submodelId, String idShortPath) {
+ return createElementTopic(submodelId, idShortPath).add(ATTACHMENT).add(DELETED).toString();
+ }
+
+ private StringJoiner createElementTopic(String submodelId, String idShortPath) {
+ return createSubmodelTopic(submodelId).add(SUBMODEL_ELEMENTS).add(idShortPath);
+ }
+
+ private StringJoiner createSubmodelTopic(String submodelId) {
+ return new StringJoiner("/", "", "").add(SUBMODEL_SERVICE).add(SUBMODELS).add(encodeId(submodelId));
}
}
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelServiceTopicFactoryTest.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelServiceTopicFactoryTest.java
new file mode 100644
index 000000000..2154101b0
--- /dev/null
+++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelServiceTopicFactoryTest.java
@@ -0,0 +1,51 @@
+/*******************************************************************************
+ * Copyright (C) 2026 the Eclipse BaSyx Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ ******************************************************************************/
+package org.eclipse.digitaltwin.basyx.submodelrepository.feature.mqtt;
+
+import static org.junit.Assert.assertEquals;
+
+import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
+import org.eclipse.digitaltwin.basyx.submodelservice.feature.mqtt.MqttSubmodelServiceTopicFactory;
+import org.junit.Test;
+
+public class MqttSubmodelServiceTopicFactoryTest {
+ private static final String SUBMODEL_ID = "https://example.org/submodels/ä?x=1";
+ private static final String ENCODED_SUBMODEL_ID = "aHR0cHM6Ly9leGFtcGxlLm9yZy9zdWJtb2RlbHMvw6Q_eD0x";
+
+ private final MqttSubmodelServiceTopicFactory topicFactory = new MqttSubmodelServiceTopicFactory(new Base64URLEncoder());
+
+ @Test
+ public void usesSubmodelIdentityAndUniformEventSuffixes() {
+ String elementPrefix = "sm-service/submodels/" + ENCODED_SUBMODEL_ID + "/submodelElements/property";
+
+ assertEquals(elementPrefix + "/created", topicFactory.createCreateSubmodelElementTopic(SUBMODEL_ID, "property"));
+ assertEquals(elementPrefix + "/updated", topicFactory.createUpdateSubmodelElementTopic(SUBMODEL_ID, "property"));
+ assertEquals(elementPrefix + "/value/updated", topicFactory.createUpdateSubmodelElementValueTopic(SUBMODEL_ID, "property"));
+ assertEquals(elementPrefix + "/deleted", topicFactory.createDeleteSubmodelElementTopic(SUBMODEL_ID, "property"));
+ assertEquals("sm-service/submodels/" + ENCODED_SUBMODEL_ID + "/submodelElements/patched", topicFactory.createPatchSubmodelElementsTopic(SUBMODEL_ID));
+ assertEquals(elementPrefix + "/attachment/updated", topicFactory.createUpdateFileValueTopic(SUBMODEL_ID, "property"));
+ assertEquals(elementPrefix + "/attachment/deleted", topicFactory.createDeleteFileValueTopic(SUBMODEL_ID, "property"));
+ }
+}
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttTestListener.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttTestListener.java
deleted file mode 100644
index c7cc88b3b..000000000
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttTestListener.java
+++ /dev/null
@@ -1,97 +0,0 @@
-/*******************************************************************************
- * Copyright (C) 2024 the Eclipse BaSyx Authors
- *
- * Permission is hereby granted, free of charge, to any person obtaining
- * a copy of this software and associated documentation files (the
- * "Software"), to deal in the Software without restriction, including
- * without limitation the rights to use, copy, modify, merge, publish,
- * distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to
- * the following conditions:
- *
- * The above copyright notice and this permission notice shall be
- * included in all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
- * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
- * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
- * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
- * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- *
- * SPDX-License-Identifier: MIT
- ******************************************************************************/
-
-package org.eclipse.digitaltwin.basyx.submodelrepository.feature.mqtt;
-
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-
-import io.moquette.interception.InterceptHandler;
-import io.moquette.interception.messages.InterceptAcknowledgedMessage;
-import io.moquette.interception.messages.InterceptConnectMessage;
-import io.moquette.interception.messages.InterceptConnectionLostMessage;
-import io.moquette.interception.messages.InterceptDisconnectMessage;
-import io.moquette.interception.messages.InterceptPublishMessage;
-import io.moquette.interception.messages.InterceptSubscribeMessage;
-import io.moquette.interception.messages.InterceptUnsubscribeMessage;
-
-/**
- * Very simple MQTT broker listener for testing API events. Stores the last
- * received event and makes its topic and payload available for reading.
- *
- * @author espen
- *
- */
-public class MqttTestListener implements InterceptHandler {
- // Topic and payload of the most recent event
- public String lastTopic;
- public String lastPayload;
- private ArrayList topics = new ArrayList<>();
-
- @Override
- public String getID() {
- return null;
- }
-
- @Override
- public Class>[] getInterceptedMessageTypes() {
- return null;
- }
-
- @Override
- public void onConnect(InterceptConnectMessage arg0) {
- }
-
- @Override
- public void onConnectionLost(InterceptConnectionLostMessage arg0) {
- }
-
- @Override
- public void onDisconnect(InterceptDisconnectMessage arg0) {
- }
-
- @Override
- public void onMessageAcknowledged(InterceptAcknowledgedMessage arg0) {
- }
-
- @Override
- public synchronized void onPublish(InterceptPublishMessage msg) {
- topics.add(msg.getTopicName());
- lastTopic = msg.getTopicName();
- lastPayload = msg.getPayload().toString(StandardCharsets.UTF_8);
- }
-
- @Override
- public void onSubscribe(InterceptSubscribeMessage arg0) {
- }
-
- @Override
- public void onUnsubscribe(InterceptUnsubscribeMessage arg0) {
- }
-
- public ArrayList getTopics() {
- return topics;
- }
-}
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/TestMqttSubmodelObserver.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/TestMqttSubmodelObserver.java
index 49c1536fb..3715d6ce8 100644
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/TestMqttSubmodelObserver.java
+++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/TestMqttSubmodelObserver.java
@@ -27,9 +27,10 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertTrue;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
@@ -39,8 +40,14 @@
import org.eclipse.digitaltwin.aas4j.v3.model.Qualifier;
import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement;
import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultProperty;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultFile;
import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultQualifier;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultSubmodelElementCollection;
+import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultSubmodelElementList;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.MqttBrokerTestSupport;
import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.Base64URLEncoder;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener;
+import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener.MqttEvent;
import org.eclipse.digitaltwin.basyx.common.mqttcore.serializer.SubmodelElementSerializer;
import org.eclipse.digitaltwin.basyx.core.filerepository.InMemoryFileRepository;
import org.eclipse.digitaltwin.basyx.submodelservice.DummySubmodelFactory;
@@ -53,20 +60,13 @@
import org.eclipse.digitaltwin.basyx.submodelservice.feature.mqtt.MqttSubmodelServiceTopicFactory;
import org.eclipse.digitaltwin.basyx.submodelservice.value.PropertyValue;
import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue;
-import org.eclipse.paho.client.mqttv3.IMqttClient;
import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttSecurityException;
import org.junit.AfterClass;
+import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
-import io.moquette.broker.Server;
-import io.moquette.broker.config.ClasspathResourceLoader;
-import io.moquette.broker.config.IConfig;
-import io.moquette.broker.config.IResourceLoader;
-import io.moquette.broker.config.ResourceLoaderConfig;
/**
* Tests events for submodelElements in SM Service
@@ -75,7 +75,7 @@
*/
public class TestMqttSubmodelObserver {
- private static Server mqttBroker;
+ private static MqttBrokerTestSupport mqttBroker;
private static MqttClient mqttClient;
private static MqttTestListener listener;
private static MqttSubmodelServiceTopicFactory topicFactory = new MqttSubmodelServiceTopicFactory(new Base64URLEncoder());
@@ -83,19 +83,21 @@ public class TestMqttSubmodelObserver {
@BeforeClass
public static void setUpClass() throws MqttException, IOException {
- mqttBroker = startBroker();
-
- listener = configureInterceptListener(mqttBroker);
-
- mqttClient = createAndConnectClient();
+ mqttBroker = MqttBrokerTestSupport.start();
+ listener = mqttBroker.listener();
+ mqttClient = mqttBroker.connectClient();
submodelService = createMqttSubmodelService(mqttClient);
}
@AfterClass
- public static void tearDownClass() {
- mqttBroker.removeInterceptHandler(listener);
- mqttBroker.stopServer();
+ public static void tearDownClass() throws MqttException {
+ mqttBroker.close();
+ }
+
+ @Before
+ public void resetSubmodelService() {
+ submodelService = createMqttSubmodelService(mqttClient);
}
@Test
@@ -104,8 +106,35 @@ public void createSubmodelElementEvent() throws DeserializationException {
SubmodelElement submodelElement = createSubmodelElementDummy("createSubmodelElementEventId");
submodelService.createSubmodelElement(submodelElement);
- assertEquals(topicFactory.createCreateSubmodelElementTopic(submodelElement.getIdShort()), listener.lastTopic);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelElementTopic(submodelService.getSubmodel().getId(), submodelElement.getIdShort()));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
+ }
+
+ @Test
+ public void createNestedSubmodelElementInCollectionEvent() throws DeserializationException {
+ String collectionIdShort = "mqttNestedCollection";
+ submodelService.createSubmodelElement(new DefaultSubmodelElementCollection.Builder().idShort(collectionIdShort).build());
+ SubmodelElement child = createSubmodelElementDummy("nestedCollectionChild");
+ String childPath = collectionIdShort + "." + child.getIdShort();
+
+ submodelService.createSubmodelElement(collectionIdShort, child);
+
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelElementTopic(submodelService.getSubmodel().getId(), childPath));
+ assertEquals(child, deserializeSubmodelElementPayload(event.payload()));
+ }
+
+ @Test
+ public void createNestedSubmodelElementInListEvent() throws DeserializationException {
+ String listIdShort = "mqttNestedList";
+ SubmodelElement existingElement = createSubmodelElementDummy("existingListElement");
+ submodelService.createSubmodelElement(new DefaultSubmodelElementList.Builder().idShort(listIdShort).value(existingElement).build());
+ SubmodelElement child = createSubmodelElementDummy("nestedListChild");
+ String childPath = listIdShort + "[1]";
+
+ submodelService.createSubmodelElement(listIdShort, child);
+
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelElementTopic(submodelService.getSubmodel().getId(), childPath));
+ assertEquals(child, deserializeSubmodelElementPayload(event.payload()));
}
@Test
@@ -117,8 +146,48 @@ public void updateSubmodelElementEvent() throws DeserializationException {
SubmodelElementValue value = new PropertyValue("updatedValue");
submodelService.setSubmodelElementValue(submodelElement.getIdShort(), value);
- assertEquals(topicFactory.createUpdateSubmodelElementTopic(submodelElement.getIdShort()), listener.lastTopic);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createUpdateSubmodelElementValueTopic(submodelService.getSubmodel().getId(), submodelElement.getIdShort()));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
+ }
+
+ @Test
+ public void replaceSubmodelElementEvent() throws DeserializationException {
+ SubmodelElement submodelElement = createSubmodelElementDummy("replaceSubmodelElementEventId");
+ submodelService.createSubmodelElement(submodelElement);
+ SubmodelElement replacement = new DefaultProperty.Builder().idShort(submodelElement.getIdShort()).value("replacementValue").build();
+
+ submodelService.updateSubmodelElement(submodelElement.getIdShort(), replacement);
+
+ MqttEvent event = listener.awaitEvent(topicFactory.createUpdateSubmodelElementTopic(submodelService.getSubmodel().getId(), submodelElement.getIdShort()));
+ assertEquals(replacement, deserializeSubmodelElementPayload(event.payload()));
+ }
+
+ @Test
+ public void patchSubmodelElementsEvent() throws DeserializationException {
+ SubmodelElement first = createSubmodelElementDummy("patchSubmodelElementOne");
+ SubmodelElement second = createSubmodelElementDummy("patchSubmodelElementTwo");
+ submodelService.createSubmodelElement(first);
+ submodelService.createSubmodelElement(second);
+
+ List patchedElements = Arrays.asList(first, second);
+ submodelService.patchSubmodelElements(patchedElements);
+
+ MqttEvent event = listener.awaitEvent(topicFactory.createPatchSubmodelElementsTopic(submodelService.getSubmodel().getId()));
+ assertEquals(patchedElements, new JsonDeserializer().readList(event.payload(), SubmodelElement.class));
+ }
+
+ @Test
+ public void attachmentUpdateAndDeleteEvents() throws Exception {
+ org.eclipse.digitaltwin.aas4j.v3.model.File file = new DefaultFile.Builder().idShort("mqttAttachmentFile").contentType("text/plain").build();
+ submodelService.createSubmodelElement(file);
+
+ submodelService.setFileValue(file.getIdShort(), "test.txt", "text/plain", new ByteArrayInputStream("file-content".getBytes(StandardCharsets.UTF_8)));
+ MqttEvent updateEvent = listener.awaitEvent(topicFactory.createUpdateFileValueTopic(submodelService.getSubmodel().getId(), file.getIdShort()));
+ assertEquals(file.getIdShort(), deserializeSubmodelElementPayload(updateEvent.payload()).getIdShort());
+
+ submodelService.deleteFileValue(file.getIdShort());
+ MqttEvent deleteEvent = listener.awaitEvent(topicFactory.createDeleteFileValueTopic(submodelService.getSubmodel().getId(), file.getIdShort()));
+ assertEquals(file.getIdShort(), deserializeSubmodelElementPayload(deleteEvent.payload()).getIdShort());
}
@Test
@@ -128,8 +197,8 @@ public void deleteSubmodelElementEvent() throws DeserializationException {
submodelService.createSubmodelElement(submodelElement);
submodelService.deleteSubmodelElement(submodelElement.getIdShort());
- assertEquals(topicFactory.createDeleteSubmodelElementTopic(submodelElement.getIdShort()), listener.lastTopic);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createDeleteSubmodelElementTopic(submodelService.getSubmodel().getId(), submodelElement.getIdShort()));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
}
@Test
@@ -140,11 +209,11 @@ public void createSubmodelElementWithoutValueEvent() throws DeserializationExcep
submodelElement.setQualifiers(qualifierList);
submodelService.createSubmodelElement(submodelElement);
- assertEquals(topicFactory.createCreateSubmodelElementTopic(submodelElement.getIdShort()), listener.lastTopic);
- assertNotEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ MqttEvent event = listener.awaitEvent(topicFactory.createCreateSubmodelElementTopic(submodelService.getSubmodel().getId(), submodelElement.getIdShort()));
+ assertNotEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
((Property) submodelElement).setValue(null);
- assertEquals(submodelElement, deserializeSubmodelElementPayload(listener.lastPayload));
+ assertEquals(submodelElement, deserializeSubmodelElementPayload(event.payload()));
}
private List createNoValueQualifierList() {
@@ -168,109 +237,4 @@ private static SubmodelService createMqttSubmodelService(MqttClient client) {
return new MqttSubmodelServiceFactory(repoFactory, client, new MqttSubmodelServiceTopicFactory(new Base64URLEncoder())).create(DummySubmodelFactory.createSubmodelWithAllSubmodelElements());
}
- private static MqttTestListener configureInterceptListener(Server broker) {
-
- MqttTestListener testListener = new MqttTestListener();
- broker.addInterceptHandler(testListener);
-
- return testListener;
- }
-
- private static MqttClient createAndConnectClient() throws MqttException, MqttSecurityException {
-
- MqttClient client = new MqttClient("tcp://localhost:1884", "testClient");
- client.connect();
- return client;
- }
-
- private static Server startBroker() throws IOException {
-
- Server broker = new Server();
- IResourceLoader classpathLoader = new ClasspathResourceLoader();
-
- IConfig classPathConfig = new ResourceLoaderConfig(classpathLoader);
- broker.startServer(classPathConfig);
-
- return broker;
- }
-
- @Test
- public void checkTCPConnectionWithoutCredentials() throws Exception {
- MqttSubmodelServiceConfiguration config = new MqttSubmodelServiceConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("", "");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkTCPConnectionWitCredentials() throws Exception {
- MqttSubmodelServiceConfiguration config = new MqttSubmodelServiceConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "passwd");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkTCPConnectionWitWrongCredentials() throws Exception {
- MqttSubmodelServiceConfiguration config = new MqttSubmodelServiceConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "false");
- boolean authentication_failed = false;
- try {
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 1884,
- "tcp",
- options);
- } catch (MqttException e) {
- if (MqttException.REASON_CODE_FAILED_AUTHENTICATION == e.getReasonCode()) {
- authentication_failed = true;
- }
- }
- assertTrue(authentication_failed);
- }
-
- @Test
- public void checkWSConnectionWithoutCredentials() throws Exception {
- MqttSubmodelServiceConfiguration config = new MqttSubmodelServiceConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("", "");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 8080,
- "ws",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
-
- @Test
- public void checkWSConnectionWitCredentials() throws Exception {
- MqttSubmodelServiceConfiguration config = new MqttSubmodelServiceConfiguration();
- MqttConnectOptions options = config.mqttConnectOptions("testuser", "passwd");
- IMqttClient client = config.mqttClient(
- "test-client",
- "localhost",
- 8080,
- "ws",
- options);
- assertTrue(client.isConnected());
- client.disconnect();
- client.close();
- }
}
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/resources/config/moquette.conf b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/resources/config/moquette.conf
deleted file mode 100644
index 94e6f3440..000000000
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/resources/config/moquette.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-# Moquette Java Broker configuration file for testing
-
-# Do not use the default 1883 port
-port 1884
-websocket_port 8080
-host 0.0.0.0
-password_file config/password_file.conf
-allow_anonymous true
\ No newline at end of file
diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/resources/config/password_file.conf b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/resources/config/password_file.conf
deleted file mode 100644
index e8062773e..000000000
--- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/resources/config/password_file.conf
+++ /dev/null
@@ -1 +0,0 @@
-testuser:0d6be69b264717f2dd33652e212b173104b4a647b7c11ae72e9885f11cd312fb
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 1b2f541bf..083a3563c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -381,11 +381,11 @@
io.moquette
moquette-broker
- 0.16
+ 0.17
org.slf4j
- slf4j-log4j12
+ slf4j-reload4j