From 78da2f24061187eeef7b07a38f800f188af05aba Mon Sep 17 00:00:00 2001 From: Aaron Zielstorff Date: Sun, 19 Jul 2026 18:30:56 +0200 Subject: [PATCH] Fix MQTT eventing and broker tests Unify MQTT topics, upgrade Moquette, and guard Docker publishing. --- .../workflows/docker-milestone-release.yml | 28 +++ .../docker-publication-guard-test.yml | 58 +++++ .github/workflows/docker-snapshot-release.yml | 28 +++ .../scripts/check_docker_publication.sh | 39 +++ .../scripts/test_docker_publication_guard.sh | 76 ++++++ .../Readme.md | 8 +- .../basyx.aasrepository-feature-mqtt/pom.xml | 9 +- .../feature/mqtt/MqttAasRepository.java | 23 +- .../mqtt/MqttAasRepositoryConfiguration.java | 47 +--- .../mqtt/MqttAasRepositoryFeature.java | 4 +- .../mqtt/MqttAasRepositoryTopicFactory.java | 4 +- .../MqttAasRepositoryTopicFactoryTest.java | 45 ++++ .../mqtt/TestMqttV2AASAggregatorObserver.java | 152 ++---------- .../src/test/resources/config/moquette.conf | 8 - .../test/resources/config/password_file.conf | 1 - .../component/TestAasRepositoryName.java | 26 ++ .../basyx.aasservice-feature-mqtt/Readme.md | 10 +- .../basyx.aasservice-feature-mqtt/pom.xml | 9 +- .../feature/mqtt/MqttAasService.java | 23 +- .../feature/mqtt/MqttAasServiceFeature.java | 4 +- .../feature/mqtt/MqttConfiguration.java | 46 +--- .../mqtt/MqttAasServiceTopicFactoryTest.java | 45 ++++ .../feature/mqtt/TestMqttAasService.java | 142 ++--------- .../src/test/resources/config/moquette.conf | 8 - .../test/resources/config/password_file.conf | 1 - basyx.common/basyx.mqttcore/pom.xml | 18 +- .../mqttcore/MqttClientConfiguration.java | 74 ++++++ .../common/mqttcore/MqttEventPublisher.java | 50 ++++ .../mqttcore/listener/MqttTestListener.java | 96 -------- .../mqttcore/MqttBrokerTestSupport.java | 202 ++++++++++++++++ .../mqttcore/MqttClientConfigurationTest.java | 152 ++++++++++++ .../mqttcore/MqttEventPublisherTest.java | 65 +++++ .../mqttcore/listener/MqttTestListener.java | 156 ++++++++++++ .../AuthorizedSubmodelRepository.java | 5 + .../Readme.md | 5 + .../pom.xml | 9 +- .../feature/mqtt/MqttSubmodelRepository.java | 36 +-- .../MqttSubmodelRepositoryConfiguration.java | 46 +--- .../MqttSubmodelRepositoryTopicFactory.java | 8 + ...qttSubmodelRepositoryTopicFactoryTest.java | 45 ++++ .../feature/mqtt/MqttTestListener.java | 96 -------- .../mqtt/TestMqttSubmodelObserver.java | 214 ++++++---------- .../src/test/resources/config/moquette.conf | 8 - .../test/resources/config/password_file.conf | 1 - ...RegistryIntegrationSubmodelRepository.java | 5 + .../search/SearchSubmodelRepository.java | 5 + .../SubmodelRepositoryDecoratorNameTest.java | 75 ++++++ .../component/TestSubmodelRepositoryName.java | 26 ++ .../SubmodelElementIdShortHelper.java | 22 ++ .../SubmodelElementIdShortHelperTest.java | 66 +++++ .../Readme.md | 19 ++ .../pom.xml | 7 + .../feature/mqtt/MqttSubmodelService.java | 61 +++-- .../MqttSubmodelServiceConfiguration.java | 46 +--- .../mqtt/MqttSubmodelServiceFactory.java | 4 +- .../mqtt/MqttSubmodelServiceTopicFactory.java | 49 +++- .../MqttSubmodelServiceTopicFactoryTest.java | 51 ++++ .../feature/mqtt/MqttTestListener.java | 97 -------- .../mqtt/TestMqttSubmodelObserver.java | 228 ++++++++---------- .../src/test/resources/config/moquette.conf | 8 - .../test/resources/config/password_file.conf | 1 - pom.xml | 4 +- 62 files changed, 1737 insertions(+), 1167 deletions(-) create mode 100644 .github/workflows/docker-publication-guard-test.yml create mode 100755 .github/workflows/scripts/check_docker_publication.sh create mode 100755 .github/workflows/scripts/test_docker_publication_guard.sh create mode 100644 basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepositoryTopicFactoryTest.java delete mode 100644 basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/resources/config/moquette.conf delete mode 100644 basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/test/resources/config/password_file.conf create mode 100644 basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasServiceTopicFactoryTest.java delete mode 100644 basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/resources/config/moquette.conf delete mode 100644 basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/resources/config/password_file.conf create mode 100644 basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttClientConfiguration.java create mode 100644 basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttEventPublisher.java delete mode 100644 basyx.common/basyx.mqttcore/src/main/java/org/eclipse/digitaltwin/basyx/common/mqttcore/listener/MqttTestListener.java create mode 100644 basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttBrokerTestSupport.java create mode 100644 basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttClientConfigurationTest.java create mode 100644 basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/MqttEventPublisherTest.java create mode 100644 basyx.common/basyx.mqttcore/src/test/java/org/eclipse/digitaltwin/basyx/common/mqttcore/listener/MqttTestListener.java create mode 100644 basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepositoryTopicFactoryTest.java delete mode 100644 basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttTestListener.java delete mode 100644 basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/resources/config/moquette.conf delete mode 100644 basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/test/resources/config/password_file.conf create mode 100644 basyx.submodelrepository/basyx.submodelrepository.component/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/component/SubmodelRepositoryDecoratorNameTest.java create mode 100644 basyx.submodelservice/basyx.submodelservice-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelElementIdShortHelperTest.java create mode 100644 basyx.submodelservice/basyx.submodelservice-feature-mqtt/Readme.md create mode 100644 basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelServiceTopicFactoryTest.java delete mode 100644 basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttTestListener.java delete mode 100644 basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/resources/config/moquette.conf delete mode 100644 basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/test/resources/config/password_file.conf 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