From 264775ddb43da8aa6816f2bf9aee65325dc981b3 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 19 Mar 2025 20:51:27 +0100 Subject: [PATCH 01/38] MQTT client reconnect strategy exponential --- .../org/thingsboard/mqtt/MqttClientImpl.java | 5 +- .../thingsboard/mqtt/ReconnectStrategy.java | 21 +++++++ .../mqtt/ReconnectStrategyExponential.java | 58 +++++++++++++++++++ .../ReconnectStrategyExponentialTest.java | 43 ++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategy.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java create mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java index 47eae565dc..344886165e 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java @@ -80,6 +80,8 @@ final class MqttClientImpl implements MqttClient { private final MqttHandler defaultHandler; + private final ReconnectStrategy reconnectStrategy; + private EventLoopGroup eventLoop; private volatile Channel channel; @@ -110,6 +112,7 @@ final class MqttClientImpl implements MqttClient { this.clientConfig = clientConfig; this.defaultHandler = defaultHandler; this.handlerExecutor = handlerExecutor; + this.reconnectStrategy = new ReconnectStrategyExponential(getClientConfig().getReconnectDelay()); } /** @@ -191,7 +194,7 @@ final class MqttClientImpl implements MqttClient { if (reconnect) { this.reconnect = true; } - eventLoop.schedule((Runnable) () -> connect(host, port, reconnect), clientConfig.getReconnectDelay(), TimeUnit.SECONDS); + eventLoop.schedule((Runnable) () -> connect(host, port, reconnect), reconnectStrategy.getNextReconnectDelay(), TimeUnit.SECONDS); } } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategy.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategy.java new file mode 100644 index 0000000000..f924b79ca5 --- /dev/null +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategy.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.mqtt; + +@FunctionalInterface +public interface ReconnectStrategy { + long getNextReconnectDelay(); +} diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java new file mode 100644 index 0000000000..dbd11f91e1 --- /dev/null +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.mqtt; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.TimeUnit; + +@Getter +@Slf4j +public class ReconnectStrategyExponential implements ReconnectStrategy { + + public static final int DEFAULT_RECONNECT_INTERVAL = 10; + final long reconnectIntervalMinSeconds; + final long reconnectIntervalMaxSeconds = 30; + long lastDisconnectNanoTime = 0; //isotonic time + int retryCount = 0; + + public ReconnectStrategyExponential(long reconnectIntervalMin) { + this.reconnectIntervalMinSeconds = calculateIntervalMin(reconnectIntervalMin); + } + + private long calculateIntervalMin(long reconnectIntervalMin) { + return Math.min((reconnectIntervalMin > 0 ? reconnectIntervalMin : DEFAULT_RECONNECT_INTERVAL), this.reconnectIntervalMaxSeconds); + } + + @Override + synchronized public long getNextReconnectDelay() { + final long currentNanoTime = getNanoTime(); + final long lastDisconnectIntervalNanos = currentNanoTime - lastDisconnectNanoTime; + lastDisconnectNanoTime = currentNanoTime; + if (TimeUnit.NANOSECONDS.toSeconds(lastDisconnectIntervalNanos) > reconnectIntervalMaxSeconds + reconnectIntervalMinSeconds) { + log.debug("Reset retry counter"); + retryCount = 0; + return reconnectIntervalMinSeconds; + } + return Math.min(reconnectIntervalMaxSeconds, reconnectIntervalMinSeconds + (1L << retryCount++)); + } + + long getNanoTime() { + return System.nanoTime(); + } + +} diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java new file mode 100644 index 0000000000..828cfae730 --- /dev/null +++ b/netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.mqtt; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.mockito.Mockito; + +import java.util.concurrent.TimeUnit; + +@Slf4j +class ReconnectStrategyExponentialTest { + + @Test + public void exponentialReconnect() { + ReconnectStrategyExponential strategy = Mockito.spy(new ReconnectStrategyExponential(1)); + for (int i = 0; i < 10; i++) { + log.info("Disconnect [{}] Delay [{}]", i, strategy.getNextReconnectDelay()); + } + + final long coolDownPeriod = strategy.getReconnectIntervalMinSeconds() + strategy.getReconnectIntervalMaxSeconds() + 1; + + BDDMockito.willAnswer((x) -> System.nanoTime() + TimeUnit.SECONDS.toNanos(coolDownPeriod)).given(strategy).getNanoTime(); + log.info("After cooldown period [{}] seconds later...", coolDownPeriod); + for (int i = 0; i < 10; i++) { + log.info("Disconnect [{}] Delay [{}]", i, strategy.getNextReconnectDelay()); + } + } +} \ No newline at end of file From 83790fa0fb6a208ca872a7c9cb704d2e8dab80e6 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Thu, 20 Mar 2025 11:02:52 +0100 Subject: [PATCH 02/38] ReconnectStrategyExponential jitter, max added, refactored, tested --- .../mqtt/ReconnectStrategyExponential.java | 50 +++++++++--- .../ReconnectStrategyExponentialTest.java | 80 +++++++++++++++---- 2 files changed, 103 insertions(+), 27 deletions(-) diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java index dbd11f91e1..e0b83c6ab7 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java @@ -18,37 +18,61 @@ package org.thingsboard.mqtt; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; @Getter @Slf4j public class ReconnectStrategyExponential implements ReconnectStrategy { - public static final int DEFAULT_RECONNECT_INTERVAL = 10; - final long reconnectIntervalMinSeconds; - final long reconnectIntervalMaxSeconds = 30; - long lastDisconnectNanoTime = 0; //isotonic time - int retryCount = 0; + public static final int DEFAULT_RECONNECT_INTERVAL_SEC = 10; + public static final int MAX_RECONNECT_INTERVAL_SEC = 60; + public static final int EXP_MAX = 8; + public static final long JITTER_MAX = 1; + private final long reconnectIntervalMinSeconds; + private final long reconnectIntervalMaxSeconds; + private long lastDisconnectNanoTime = 0; //isotonic time + private long retryCount = 0; - public ReconnectStrategyExponential(long reconnectIntervalMin) { - this.reconnectIntervalMinSeconds = calculateIntervalMin(reconnectIntervalMin); + public ReconnectStrategyExponential(long reconnectIntervalMinSeconds) { + this.reconnectIntervalMaxSeconds = calculateIntervalMax(reconnectIntervalMinSeconds); + this.reconnectIntervalMinSeconds = calculateIntervalMin(reconnectIntervalMinSeconds); } - private long calculateIntervalMin(long reconnectIntervalMin) { - return Math.min((reconnectIntervalMin > 0 ? reconnectIntervalMin : DEFAULT_RECONNECT_INTERVAL), this.reconnectIntervalMaxSeconds); + long calculateIntervalMax(long reconnectIntervalMinSeconds) { + return reconnectIntervalMinSeconds > MAX_RECONNECT_INTERVAL_SEC ? reconnectIntervalMinSeconds : MAX_RECONNECT_INTERVAL_SEC; + } + + long calculateIntervalMin(long reconnectIntervalMinSeconds) { + return Math.min((reconnectIntervalMinSeconds > 0 ? reconnectIntervalMinSeconds : DEFAULT_RECONNECT_INTERVAL_SEC), this.reconnectIntervalMaxSeconds); } @Override synchronized public long getNextReconnectDelay() { final long currentNanoTime = getNanoTime(); - final long lastDisconnectIntervalNanos = currentNanoTime - lastDisconnectNanoTime; + final long coolDownSpentNanos = currentNanoTime - lastDisconnectNanoTime; lastDisconnectNanoTime = currentNanoTime; - if (TimeUnit.NANOSECONDS.toSeconds(lastDisconnectIntervalNanos) > reconnectIntervalMaxSeconds + reconnectIntervalMinSeconds) { - log.debug("Reset retry counter"); + if (isCooledDown(coolDownSpentNanos)) { retryCount = 0; return reconnectIntervalMinSeconds; } - return Math.min(reconnectIntervalMaxSeconds, reconnectIntervalMinSeconds + (1L << retryCount++)); + return calculateNextReconnectDelay() + calculateJitter(); + } + + long calculateJitter() { + return ThreadLocalRandom.current().nextInt() >= 0 ? JITTER_MAX : 0; + } + + long calculateNextReconnectDelay() { + return Math.min(reconnectIntervalMaxSeconds, reconnectIntervalMinSeconds + calculateExp(retryCount++)); + } + + long calculateExp(long e) { + return 1L << Math.min(e, EXP_MAX); + } + + boolean isCooledDown(long coolDownSpentNanos) { + return TimeUnit.NANOSECONDS.toSeconds(coolDownSpentNanos) > reconnectIntervalMaxSeconds + reconnectIntervalMinSeconds; } long getNanoTime() { diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java index 828cfae730..085dcef3a6 100644 --- a/netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java +++ b/netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java @@ -16,28 +16,80 @@ package org.thingsboard.mqtt; import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.Test; -import org.mockito.BDDMockito; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; +import org.mockito.stubbing.Answer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.Offset.offset; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willAnswer; +import static org.thingsboard.mqtt.ReconnectStrategyExponential.EXP_MAX; +import static org.thingsboard.mqtt.ReconnectStrategyExponential.JITTER_MAX; @Slf4j class ReconnectStrategyExponentialTest { - @Test - public void exponentialReconnect() { - ReconnectStrategyExponential strategy = Mockito.spy(new ReconnectStrategyExponential(1)); - for (int i = 0; i < 10; i++) { - log.info("Disconnect [{}] Delay [{}]", i, strategy.getNextReconnectDelay()); - } + @Execution(ExecutionMode.SAME_THREAD) // just for convenient log reading + @ParameterizedTest + @ValueSource(ints = {1, 0, 60}) + public void exponentialReconnectDelayTest(final int reconnectIntervalMinSeconds) { + final ReconnectStrategyExponential strategy = Mockito.spy(new ReconnectStrategyExponential(reconnectIntervalMinSeconds)); + log.info("=== Reconnect delay test for ReconnectStrategyExponential({}) : calculated min [{}] max [{}] ===", reconnectIntervalMinSeconds, strategy.getReconnectIntervalMinSeconds(), strategy.getReconnectIntervalMaxSeconds()); + final AtomicLong nanoTime = new AtomicLong(System.nanoTime()); + willAnswer((x) -> nanoTime.get()).given(strategy).getNanoTime(); + final LinkedBlockingDeque jittersCaptured = new LinkedBlockingDeque<>(); + final LinkedBlockingDeque expCaptured = new LinkedBlockingDeque<>(); - final long coolDownPeriod = strategy.getReconnectIntervalMinSeconds() + strategy.getReconnectIntervalMaxSeconds() + 1; + willAnswer(captureResult(jittersCaptured)).given(strategy).calculateJitter(); + willAnswer(captureResult(expCaptured)).given(strategy).calculateExp(anyLong()); - BDDMockito.willAnswer((x) -> System.nanoTime() + TimeUnit.SECONDS.toNanos(coolDownPeriod)).given(strategy).getNanoTime(); - log.info("After cooldown period [{}] seconds later...", coolDownPeriod); - for (int i = 0; i < 10; i++) { - log.info("Disconnect [{}] Delay [{}]", i, strategy.getNextReconnectDelay()); + for (int phase = 0; phase < 3; phase++) { + log.info("== Phase {} ==", phase); + long previousDelay = 0; + for (int i = 0; i < EXP_MAX + 4; i++) { + final long nextReconnectDelay = strategy.getNextReconnectDelay(); + nanoTime.addAndGet(TimeUnit.SECONDS.toNanos(nextReconnectDelay)); + log.info("Retry [{}] Delay [{}] : min [{}] exp [{}] jitter [{}]", strategy.getRetryCount(), nextReconnectDelay, strategy.getReconnectIntervalMinSeconds(), expCaptured.peekLast(), jittersCaptured.peekLast()); + assertThat(previousDelay).satisfiesAnyOf( + v -> assertThat(v).isLessThanOrEqualTo(nextReconnectDelay), + v -> assertThat(v).isCloseTo(nextReconnectDelay, offset(JITTER_MAX)) // Adjust tolerance as needed + ); + previousDelay = nextReconnectDelay; + } + log.info("Jitters captured: {}", drainAll(jittersCaptured)); + log.info("Exponents captured: {}", drainAll(expCaptured)); + assertThat(previousDelay).isCloseTo(strategy.getReconnectIntervalMaxSeconds(), offset(JITTER_MAX)); + + final long coolDownPeriodSec = strategy.getReconnectIntervalMinSeconds() + strategy.getReconnectIntervalMaxSeconds() + 1; + log.info("Cooling down for [{}] seconds ...", coolDownPeriodSec); + nanoTime.addAndGet(TimeUnit.SECONDS.toNanos(coolDownPeriodSec)); + assertThat(strategy.isCooledDown(TimeUnit.SECONDS.toNanos(coolDownPeriodSec))).as("cooled down").isTrue(); } } -} \ No newline at end of file + + private Answer captureResult(Collection collection) { + return invocation -> { + long result = (long) invocation.callRealMethod(); + collection.add(result); + return result; + }; + } + + private Collection drainAll(BlockingQueue jittersCaptured) { + Collection elements = new ArrayList<>(); + jittersCaptured.drainTo(elements); + return elements; + } + +} From 26d949d22fc8443ca06bfdff966da80608ddae0b Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 16 Apr 2025 16:18:21 +0200 Subject: [PATCH 03/38] invalidate async js scripts --- .../api/AbstractScriptInvokeService.java | 4 +- .../api/js/AbstractJsInvokeService.java | 17 +++ .../script/api/js/JsValidator.java | 120 ++++++++++++++++++ .../api/js/AbstractJsInvokeServiceTest.java | 91 +++++++++++++ .../script/api/js/JsValidatorTest.java | 88 +++++++++++++ 5 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java create mode 100644 common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java create mode 100644 common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java index 3d9b855064..f7b640d3af 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java @@ -269,7 +269,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService } } - private boolean scriptBodySizeExceeded(String scriptBody) { + public boolean scriptBodySizeExceeded(String scriptBody) { if (getMaxScriptBodySize() <= 0) return false; return scriptBody.length() > getMaxScriptBodySize(); } @@ -297,7 +297,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService return result != null && result.length() > getMaxResultSize(); } - private ListenableFuture error(String message) { + public ListenableFuture error(String message) { return Futures.immediateFailedFuture(new RuntimeException(message)); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java index b6ddf16e66..d8fae31290 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java @@ -35,6 +35,8 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import static java.lang.String.format; + /** * Created by ashvayka on 26.09.18. */ @@ -93,6 +95,21 @@ public abstract class AbstractJsInvokeService extends AbstractScriptInvokeServic doRelease(scriptId, scriptInfoMap.remove(scriptId)); } + @Override + public ListenableFuture eval(TenantId tenantId, ScriptType scriptType, String scriptBody, String... argNames) { + if (!isExecEnabled(tenantId)) { + return error("Script Execution is disabled due to API limits!"); + } + if (scriptBodySizeExceeded(scriptBody)) { + return error(format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize())); + } + final String validationIssue = JsValidator.validate(scriptBody); + if (validationIssue != null ) { + return error(validationIssue); + } + return super.eval(tenantId, scriptType, scriptBody, argNames); + } + protected abstract ListenableFuture doEval(UUID scriptId, JsScriptInfo jsInfo, String scriptBody); protected abstract ListenableFuture doInvokeFunction(UUID scriptId, JsScriptInfo jsInfo, Object[] args); diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java new file mode 100644 index 0000000000..9c65064802 --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.script.api.js; + +import java.util.regex.Pattern; + +public class JsValidator { + + static final Pattern ASYNC_PATTERN = Pattern.compile("\\basync\\b"); + static final Pattern AWAIT_PATTERN = Pattern.compile("\\bawait\\b"); + static final Pattern PROMISE_PATTERN = Pattern.compile("\\bPromise\\b"); + static final Pattern SET_TIMEOUT_PATTERN = Pattern.compile("\\bsetTimeout\\b"); + + public static String validate(String scriptBody) { + if (scriptBody == null || scriptBody.trim().isEmpty()) { + return "Script body is empty"; + } + + //Quick check + if (!ASYNC_PATTERN.matcher(scriptBody).find() + && !AWAIT_PATTERN.matcher(scriptBody).find() + && !PROMISE_PATTERN.matcher(scriptBody).find() + && !SET_TIMEOUT_PATTERN.matcher(scriptBody).find()) { + return null; + } + + //Recheck if quick check failed. Ignoring comments and strings + String[] lines = scriptBody.split("\\r?\\n"); + boolean insideMultilineComment = false; + + for (String line : lines) { + String stripped = line; + + // Handle multiline comments + if (insideMultilineComment) { + if (line.contains("*/")) { + insideMultilineComment = false; + stripped = line.substring(line.indexOf("*/") + 2); // continue after comment + } else { + continue; // skip line inside multiline comment + } + } + + // Check for start of multiline comment + if (stripped.contains("/*")) { + int start = stripped.indexOf("/*"); + int end = stripped.indexOf("*/", start + 2); + + if (end != -1) { + // Inline multiline comment + stripped = stripped.substring(0, start) + stripped.substring(end + 2); + } else { + // Starts a block comment, continues on next lines + insideMultilineComment = true; + stripped = stripped.substring(0, start); + } + } + + stripped = stripInlineComment(stripped); + stripped = stripStringLiterals(stripped); + + if (ASYNC_PATTERN.matcher(stripped).find()) { + return "Script must not contain 'async' keyword."; + } + if (AWAIT_PATTERN.matcher(stripped).find()) { + return "Script must not contain 'await' keyword."; + } + if (PROMISE_PATTERN.matcher(stripped).find()) { + return "Script must not use 'Promise'."; + } + if (SET_TIMEOUT_PATTERN.matcher(stripped).find()) { + return "Script must not use 'setTimeout' method."; + } + } + return null; + } + + private static String stripInlineComment(String line) { + int index = line.indexOf("//"); + return index >= 0 ? line.substring(0, index) : line; + } + + private static String stripStringLiterals(String line) { + StringBuilder sb = new StringBuilder(); + boolean inSingleQuote = false; + boolean inDoubleQuote = false; + + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + + if (c == '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } else if (c == '\'' && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (!inSingleQuote && !inDoubleQuote) { + sb.append(c); + } + } + + return sb.toString(); + } + +} diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java new file mode 100644 index 0000000000..760ca9e3fb --- /dev/null +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java @@ -0,0 +1,91 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.script.api.js; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import org.thingsboard.server.common.stats.StatsCounter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.script.api.ScriptType; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@Slf4j +class AbstractJsInvokeServiceTest { + + AbstractJsInvokeService service; + final UUID id = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = mock(AbstractJsInvokeService.class, Mockito.RETURNS_DEEP_STUBS); + + ReflectionTestUtils.setField(service, "requestsCounter", mock(StatsCounter.class)); + ReflectionTestUtils.setField(service, "evalCallback", mock(FutureCallback.class)); + + // Make sure core checks always pass + doReturn(true).when(service).isExecEnabled(any()); + doReturn(false).when(service).scriptBodySizeExceeded(anyString()); + doReturn(Futures.immediateFuture(id)).when(service).doEvalScript(any(), any(), anyString(), any(), any(String[].class)); + + // Use real implementation of eval() + doCallRealMethod().when(service).eval(any(), any(), any(), any(String[].class)); + doCallRealMethod().when(service).error(anyString()); + } + + @Test + void shouldReturnValidationErrorFromJsValidator() throws ExecutionException, InterruptedException { + String scriptWithAsync = "async function test() {}"; + + var future = service.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptWithAsync, "a", "b"); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertTrue(ex.getCause().getMessage().contains("Script must not contain 'async' keyword.")); + assertThat(ex.getCause()).isInstanceOf(RuntimeException.class); + verify(service).isExecEnabled(any()); + verify(service).scriptBodySizeExceeded(any()); + } + + @Test + void shouldPassValidationAndCallSuperEval() throws ExecutionException, InterruptedException, TimeoutException { + String validScript = "function test() { return 42; }"; + var result = service.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, validScript, "x", "y"); + + assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(id); + // two times, non-optimal + verify(service, times(2)).isExecEnabled(any()); + verify(service, times(2)).scriptBodySizeExceeded(any()); + } + +} diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java new file mode 100644 index 0000000000..c1dbf7a58b --- /dev/null +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java @@ -0,0 +1,88 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.script.api.js; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class JsValidatorTest { + + @ParameterizedTest(name = "should return error for script \"{0}\"") + @ValueSource(strings = { + "async function test() {}", + "const result = await someFunc();", + "const result =\nawait\tsomeFunc();", + "setTimeout(1000);", + "new Promise((resolve) => {});", + "function test() { return 42; } \n\t await test()", + """ + function init() { + await doSomething(); + } + """, + }) + void shouldReturnErrorForInvalidScripts(String script) { + assertNotNull(JsValidator.validate(script)); + } + + @ParameterizedTest(name = "should pass validation for script: \"{0}\"") + @ValueSource(strings = { + "function test() { return 42; }", + "const result = 10 * 2;", + "// async is a keyword but not used: 'const word = \"async\";'", + "let note = 'setTimeout tight';", + + "const word = \"async\";", + "const word = \"setTimeout\";", + "const word = \"Promise\";", + "const word = \"await\";", + + "const word = 'async';", + "const word = 'setTimeout';", + "const word = 'Promise';", + "const word = 'await';", + + "//function test() { return 42; }", + "// const result = 10 * 2;", + "// async is a keyword but not used: 'const word = \"async\";'", + "//setTimeout(1);", + + "a=b+c; // await for a day", + "return new // Promise((resolve) => {", + "hello(); // async is a keyword but not used: 'const word = \"async\";'", + "setGoal(a); //setTimeout(1);", + + " /* new Promise((resolve) => {}); // */ return 'await';", + " /* async */ function calc() {", + "/* async function abc() { \n await new Promise ( \t setTimeout () ) \n } \n*/", + }) + void shouldReturnNullForValidScripts(String script) { + assertNull(JsValidator.validate(script)); + } + + @ParameterizedTest(name = "should return 'Script body is empty' for input: \"{0}\"") + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + void shouldReturnErrorForEmptyOrNullScripts(String script) { + assertEquals("Script body is empty", JsValidator.validate(script)); + } + +} From 5a46a170e45c67f77ba7062579400ba421618c78 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 16 Apr 2025 16:47:20 +0200 Subject: [PATCH 04/38] MQTT client log added: Scheduling reconnect in [{}] sec --- .../src/main/java/org/thingsboard/mqtt/MqttClientImpl.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java index 344886165e..59a680662f 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java @@ -194,7 +194,10 @@ final class MqttClientImpl implements MqttClient { if (reconnect) { this.reconnect = true; } - eventLoop.schedule((Runnable) () -> connect(host, port, reconnect), reconnectStrategy.getNextReconnectDelay(), TimeUnit.SECONDS); + + final long nextReconnectDelay = reconnectStrategy.getNextReconnectDelay(); + log.info("[{}] Scheduling reconnect in [{}] sec", channel != null ? channel.id() : "UNKNOWN", nextReconnectDelay); + eventLoop.schedule((Runnable) () -> connect(host, port, reconnect), nextReconnectDelay, TimeUnit.SECONDS); } } From 2a50e2eaa53b445488154297ab67bdcc9a672b30 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 22 Apr 2025 16:38:52 +0200 Subject: [PATCH 05/38] AbstractScriptInvokeService: validate script refactored --- .../api/AbstractScriptInvokeService.java | 26 ++-- .../api/js/AbstractJsInvokeService.java | 16 +-- .../api/AbstractScriptInvokeServiceTest.java | 122 ++++++++++++++++++ .../api/js/AbstractJsInvokeServiceTest.java | 8 +- 4 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 common/script/script-api/src/test/java/org/thingsboard/script/api/AbstractScriptInvokeServiceTest.java diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java index f7b640d3af..0b3890b4bc 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java @@ -134,19 +134,29 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService } } - @Override - public ListenableFuture eval(TenantId tenantId, ScriptType scriptType, String scriptBody, String... argNames) { + public String validate(TenantId tenantId, String scriptBody) { if (isExecEnabled(tenantId)) { if (scriptBodySizeExceeded(scriptBody)) { - return error(format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize())); + return format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize()); } - UUID scriptId = UUID.randomUUID(); - requestsCounter.increment(); - return withTimeoutAndStatsCallback(scriptId, null, - doEvalScript(tenantId, scriptType, scriptBody, scriptId, argNames), evalCallback, getMaxEvalRequestsTimeout()); } else { - return error("Script Execution is disabled due to API limits!"); + return "Script Execution is disabled due to API limits!"; } + + return null; + } + + @Override + public ListenableFuture eval(TenantId tenantId, ScriptType scriptType, String scriptBody, String... argNames) { + String validationError = validate(tenantId, scriptBody); + if (validationError != null) { + return error(validationError); + } + + UUID scriptId = UUID.randomUUID(); + requestsCounter.increment(); + return withTimeoutAndStatsCallback(scriptId, null, + doEvalScript(tenantId, scriptType, scriptBody, scriptId, argNames), evalCallback, getMaxEvalRequestsTimeout()); } @Override diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java index d8fae31290..c859f49629 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java @@ -96,18 +96,12 @@ public abstract class AbstractJsInvokeService extends AbstractScriptInvokeServic } @Override - public ListenableFuture eval(TenantId tenantId, ScriptType scriptType, String scriptBody, String... argNames) { - if (!isExecEnabled(tenantId)) { - return error("Script Execution is disabled due to API limits!"); + public String validate(TenantId tenantId, String scriptBody) { + String errorMessage = super.validate(tenantId, scriptBody); + if (errorMessage == null) { + return JsValidator.validate(scriptBody); } - if (scriptBodySizeExceeded(scriptBody)) { - return error(format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize())); - } - final String validationIssue = JsValidator.validate(scriptBody); - if (validationIssue != null ) { - return error(validationIssue); - } - return super.eval(tenantId, scriptType, scriptBody, argNames); + return errorMessage; } protected abstract ListenableFuture doEval(UUID scriptId, JsScriptInfo jsInfo, String scriptBody); diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/AbstractScriptInvokeServiceTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/AbstractScriptInvokeServiceTest.java new file mode 100644 index 0000000000..35f201ed3b --- /dev/null +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/AbstractScriptInvokeServiceTest.java @@ -0,0 +1,122 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.script.api; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.StatsCounter; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class AbstractScriptInvokeServiceTest { + + AbstractScriptInvokeService service; + final UUID id = UUID.randomUUID(); + final String scriptBody = "return true;"; + final TenantId tenantId = TenantId.fromUUID(UUID.fromString("2ed9a658-45a5-4812-b212-9931f5749f30")); + + @BeforeEach + void setUp() { + service = mock(AbstractScriptInvokeService.class, Mockito.RETURNS_DEEP_STUBS); + + // Make sure core checks always pass + doReturn(true).when(service).isExecEnabled(any()); + doReturn(50000L).when(service).getMaxScriptBodySize(); + + // Use real implementations + doCallRealMethod().when(service).scriptBodySizeExceeded(anyString()); + doCallRealMethod().when(service).eval(any(), any(), any(), any(String[].class)); + doCallRealMethod().when(service).error(anyString()); + doCallRealMethod().when(service).validate(any(), anyString()); + } + + @Test + void evalWithValidationCallTest() throws ExecutionException, InterruptedException, TimeoutException { + ReflectionTestUtils.setField(service, "requestsCounter", mock(StatsCounter.class)); + ReflectionTestUtils.setField(service, "evalCallback", mock(FutureCallback.class)); + + doReturn(Futures.immediateFuture(id)).when(service).doEvalScript(any(), any(), anyString(), any(), any(String[].class)); + + var future = service.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, scriptBody, "x", "y"); + + assertThat(future.get(30, TimeUnit.SECONDS)).isEqualTo(id); + verify(service).validate(any(), anyString()); + verify(service).validate(tenantId, scriptBody); + verify(service, never()).error(anyString()); + } + + @Test + void evalWithValidationCallErrorTest() throws ExecutionException, InterruptedException, TimeoutException { + doReturn(false).when(service).isExecEnabled(any()); + var future = service.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, scriptBody, "x", "y"); + + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertThat(ex.getCause().getMessage()).isEqualTo("Script Execution is disabled due to API limits!"); + assertThat(ex.getCause()).isInstanceOf(RuntimeException.class); + + verify(service).validate(any(), anyString()); + verify(service).validate(tenantId, scriptBody); + verify(service).error(anyString()); + } + + @Test + void validateScriptBodyTestExecEnabledTest() { + assertNull(service.validate(tenantId, scriptBody)); + verify(service).isExecEnabled(tenantId); + } + + @Test + void validateScriptBodyTestExecDisabledTest() { + doReturn(false).when(service).isExecEnabled(tenantId); + assertThat(service.validate(tenantId, scriptBody)).isEqualTo("Script Execution is disabled due to API limits!"); + verify(service).isExecEnabled(tenantId); + } + + @Test + void validateScriptBodySizeOKTest() { + assertNull(service.validate(tenantId, scriptBody)); + verify(service).isExecEnabled(tenantId); + verify(service).scriptBodySizeExceeded(scriptBody); + } + + @Test + void validateScriptBodySizeExceededTest() { + doReturn(10L).when(service).getMaxScriptBodySize(); + assertThat(service.validate(tenantId, scriptBody)).isEqualTo("Script body exceeds maximum allowed size of 10 symbols"); + verify(service).isExecEnabled(tenantId); + verify(service).scriptBodySizeExceeded(scriptBody); + } + +} diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java index 760ca9e3fb..3f7d61919d 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java @@ -60,9 +60,10 @@ class AbstractJsInvokeServiceTest { doReturn(false).when(service).scriptBodySizeExceeded(anyString()); doReturn(Futures.immediateFuture(id)).when(service).doEvalScript(any(), any(), anyString(), any(), any(String[].class)); - // Use real implementation of eval() + // Use real implementations doCallRealMethod().when(service).eval(any(), any(), any(), any(String[].class)); doCallRealMethod().when(service).error(anyString()); + doCallRealMethod().when(service).validate(any(), anyString()); } @Test @@ -83,9 +84,8 @@ class AbstractJsInvokeServiceTest { var result = service.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, validScript, "x", "y"); assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(id); - // two times, non-optimal - verify(service, times(2)).isExecEnabled(any()); - verify(service, times(2)).scriptBodySizeExceeded(any()); + verify(service, times(1)).isExecEnabled(any()); + verify(service, times(1)).scriptBodySizeExceeded(any()); } } From 49289da5a8e15e99d4a8776f935a2f38a2d6f1fc Mon Sep 17 00:00:00 2001 From: Tarnavskiy Date: Wed, 23 Apr 2025 17:55:51 +0300 Subject: [PATCH 06/38] Fixed an issue when the user could break the pagination settings validation by switching between the basic/advanced mode tabs in table-widgets --- .../widget/lib/alarm/alarms-table-widget.component.ts | 8 +++++--- .../widget/lib/entity/entities-table-widget.component.ts | 8 +++++--- .../widget/lib/rpc/persistent-table.component.ts | 8 +++++--- .../home/components/widget/lib/table-widget.models.ts | 6 +++--- .../widget/lib/timeseries-table-widget.component.ts | 8 +++++--- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index 7384f76b62..31843506cc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -392,10 +392,12 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'alarm, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepIncrement = this.settings.pageStepIncrement; - let pageStepCount = this.settings.pageStepCount; + let pageStepIncrement = Number.isInteger(this.settings.pageStepIncrement) && this.settings.pageStepIncrement > 0 ? + this.settings.pageStepIncrement : null; + let pageStepCount = Number.isInteger(this.settings.pageStepCount) && this.settings.pageStepCount > 0 + && this.settings.pageStepCount <= 100 ? this.settings.pageStepCount : null; - if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { + if (Number.isInteger(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index 653c388364..a80c6896bb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -311,10 +311,12 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'entity, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepIncrement = this.settings.pageStepIncrement; - let pageStepCount = this.settings.pageStepCount; + let pageStepIncrement = Number.isInteger(this.settings.pageStepIncrement) && this.settings.pageStepIncrement > 0 ? + this.settings.pageStepIncrement : null; + let pageStepCount = Number.isInteger(this.settings.pageStepCount) && this.settings.pageStepCount > 0 + && this.settings.pageStepCount <= 100 ? this.settings.pageStepCount : null; - if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { + if (Number.isInteger(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts index 586f425b13..0647f41771 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts @@ -207,10 +207,12 @@ export class PersistentTableComponent extends PageComponent implements OnInit, O this.displayedColumns = [...this.displayTableColumns]; const pageSize = this.settings.defaultPageSize; - let pageStepIncrement = this.settings.pageStepIncrement; - let pageStepCount = this.settings.pageStepCount; + let pageStepIncrement = Number.isInteger(this.settings.pageStepIncrement) && this.settings.pageStepIncrement > 0 ? + this.settings.pageStepIncrement : null; + let pageStepCount = Number.isInteger(this.settings.pageStepCount) && this.settings.pageStepCount > 0 + && this.settings.pageStepCount <= 100 ? this.settings.pageStepCount : null; - if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { + if (Number.isInteger(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 8aadc62ee4..eb944e3c65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -16,7 +16,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { DataKey, FormattedData, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models'; -import { getDescendantProp, isDefined, isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; +import { getDescendantProp, isDefined, isNotEmptyStr } from '@core/utils'; import { AlarmDataInfo, alarmFields } from '@shared/models/alarm.models'; import tinycolor from 'tinycolor2'; import { Direction } from '@shared/models/page/sort-order'; @@ -564,8 +564,8 @@ export function getHeaderTitle(dataKey: DataKey, keySettings: TableWidgetDataKey export function buildPageStepSizeValues(pageStepCount: number, pageStepIncrement: number): Array { const pageSteps: Array = []; - if (isDefinedAndNotNull(pageStepCount) && pageStepCount > 0 && pageStepCount <= 100 && - isDefinedAndNotNull(pageStepIncrement) && pageStepIncrement > 0) { + if (Number.isInteger(pageStepCount) && pageStepCount > 0 && pageStepCount <= 100 && + Number.isInteger(pageStepIncrement) && pageStepIncrement > 0) { for (let i = 1; i <= pageStepCount; i++) { pageSteps.push(pageStepIncrement * i); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 2653b79059..010e49fe32 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -352,10 +352,12 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'rowData, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepIncrement = this.settings.pageStepIncrement; - let pageStepCount = this.settings.pageStepCount; + let pageStepIncrement = Number.isInteger(this.settings.pageStepIncrement) && this.settings.pageStepIncrement > 0 ? + this.settings.pageStepIncrement : null; + let pageStepCount = Number.isInteger(this.settings.pageStepCount) && this.settings.pageStepCount > 0 + && this.settings.pageStepCount <= 100 ? this.settings.pageStepCount : null; - if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { + if (Number.isInteger(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } From 358e635805dfde918eedc4543f839d8e3115a9ce Mon Sep 17 00:00:00 2001 From: Tarnavskiy Date: Wed, 23 Apr 2025 18:34:58 +0300 Subject: [PATCH 07/38] Code optimization for a fix of an issue with broken pagination settings validation in table-widgets --- .../widget/lib/alarm/alarms-table-widget.component.ts | 9 ++++----- .../lib/entity/entities-table-widget.component.ts | 10 +++++----- .../widget/lib/rpc/persistent-table.component.ts | 10 +++++----- .../home/components/widget/lib/table-widget.models.ts | 11 +++++++++-- .../widget/lib/timeseries-table-widget.component.ts | 9 ++++----- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index 31843506cc..5321e27c05 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -43,7 +43,6 @@ import { isDefined, isDefinedAndNotNull, isNotEmptyStr, - isNumber, isObject, isUndefined } from '@core/utils'; @@ -77,6 +76,8 @@ import { getHeaderTitle, getRowStyleInfo, getTableCellButtonActions, + isValidPageStepCount, + isValidPageStepIncrement, noDataMessage, prepareTableCellButtonActions, RowStyleInfo, @@ -392,10 +393,8 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'alarm, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepIncrement = Number.isInteger(this.settings.pageStepIncrement) && this.settings.pageStepIncrement > 0 ? - this.settings.pageStepIncrement : null; - let pageStepCount = Number.isInteger(this.settings.pageStepCount) && this.settings.pageStepCount > 0 - && this.settings.pageStepCount <= 100 ? this.settings.pageStepCount : null; + let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null; + let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null; if (Number.isInteger(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index a80c6896bb..2a87af2519 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -42,7 +42,7 @@ import { import { IWidgetSubscription } from '@core/api/widget-api.models'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; -import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isNumber, isObject, isUndefined } from '@core/utils'; +import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isObject, isUndefined } from '@core/utils'; import cssjs from '@core/css/css'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @@ -75,6 +75,8 @@ import { getHeaderTitle, getRowStyleInfo, getTableCellButtonActions, + isValidPageStepCount, + isValidPageStepIncrement, noDataMessage, prepareTableCellButtonActions, RowStyleInfo, @@ -311,10 +313,8 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'entity, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepIncrement = Number.isInteger(this.settings.pageStepIncrement) && this.settings.pageStepIncrement > 0 ? - this.settings.pageStepIncrement : null; - let pageStepCount = Number.isInteger(this.settings.pageStepCount) && this.settings.pageStepCount > 0 - && this.settings.pageStepCount <= 100 ? this.settings.pageStepCount : null; + let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null; + let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null; if (Number.isInteger(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts index 0647f41771..60f522b192 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts @@ -36,6 +36,8 @@ import { BehaviorSubject, merge, Observable, of, ReplaySubject, Subject, throwEr import { catchError, map, tap } from 'rxjs/operators'; import { constructTableCssString, + isValidPageStepCount, + isValidPageStepIncrement, noDataMessage, TableCellButtonActionDescriptor, TableWidgetSettings @@ -43,7 +45,7 @@ import { import cssjs from '@core/css/css'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; -import { hashCode, isDefined, isDefinedAndNotNull, isNumber, parseHttpErrorMessage } from '@core/utils'; +import { hashCode, isDefined, isDefinedAndNotNull, parseHttpErrorMessage } from '@core/utils'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { @@ -207,10 +209,8 @@ export class PersistentTableComponent extends PageComponent implements OnInit, O this.displayedColumns = [...this.displayTableColumns]; const pageSize = this.settings.defaultPageSize; - let pageStepIncrement = Number.isInteger(this.settings.pageStepIncrement) && this.settings.pageStepIncrement > 0 ? - this.settings.pageStepIncrement : null; - let pageStepCount = Number.isInteger(this.settings.pageStepCount) && this.settings.pageStepCount > 0 - && this.settings.pageStepCount <= 100 ? this.settings.pageStepCount : null; + let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null; + let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null; if (Number.isInteger(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index eb944e3c65..1ff7a21d71 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -564,11 +564,18 @@ export function getHeaderTitle(dataKey: DataKey, keySettings: TableWidgetDataKey export function buildPageStepSizeValues(pageStepCount: number, pageStepIncrement: number): Array { const pageSteps: Array = []; - if (Number.isInteger(pageStepCount) && pageStepCount > 0 && pageStepCount <= 100 && - Number.isInteger(pageStepIncrement) && pageStepIncrement > 0) { + if (isValidPageStepCount(pageStepCount) && isValidPageStepIncrement(pageStepIncrement)) { for (let i = 1; i <= pageStepCount; i++) { pageSteps.push(pageStepIncrement * i); } } return pageSteps; } + +export function isValidPageStepIncrement(value: number): boolean { + return Number.isInteger(value) && value > 0; +} + +export function isValidPageStepCount(value: number): boolean { + return Number.isInteger(value) && value > 0 && value <= 100; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 010e49fe32..2e3a64c190 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -49,7 +49,6 @@ import { hashCode, isDefined, isDefinedAndNotNull, - isNumber, isObject, isUndefined } from '@core/utils'; @@ -85,6 +84,8 @@ import { getColumnSelectionAvailability, getRowStyleInfo, getTableCellButtonActions, + isValidPageStepCount, + isValidPageStepIncrement, noDataMessage, prepareTableCellButtonActions, RowStyleInfo, @@ -352,10 +353,8 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'rowData, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepIncrement = Number.isInteger(this.settings.pageStepIncrement) && this.settings.pageStepIncrement > 0 ? - this.settings.pageStepIncrement : null; - let pageStepCount = Number.isInteger(this.settings.pageStepCount) && this.settings.pageStepCount > 0 - && this.settings.pageStepCount <= 100 ? this.settings.pageStepCount : null; + let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null; + let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null; if (Number.isInteger(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; From 8c178a26477bd3c6960143586441804da617f42a Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 30 Apr 2025 16:46:12 +0300 Subject: [PATCH 08/38] UI: Fixed incorrect help links for calculated fields --- ui-ngx/src/app/shared/models/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index e1306e577c..af576882b8 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -198,7 +198,7 @@ export const HelpLinks = { mobileApplication: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/applications/`, mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`, mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`, - calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/`, + calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`, timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`, } }; From ff3c1e27ed83336a9d3d46b9c748d4ae562993a7 Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Wed, 30 Apr 2025 23:47:19 +0300 Subject: [PATCH 09/38] =?UTF-8?q?UI:=20Improved=20Nederlands=20(Belgi?= =?UTF-8?q?=C3=AB)=20translation=20remove=20duplicate=20tranlate=20and=20i?= =?UTF-8?q?nvalid=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assets/locale/locale.constant-nl_BE.json | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/ui-ngx/src/assets/locale/locale.constant-nl_BE.json b/ui-ngx/src/assets/locale/locale.constant-nl_BE.json index 348eea95b0..e64970bca3 100644 --- a/ui-ngx/src/assets/locale/locale.constant-nl_BE.json +++ b/ui-ngx/src/assets/locale/locale.constant-nl_BE.json @@ -59,8 +59,6 @@ "import": "Importeren", "export": "Exporteren", "share-via": "Delen via {{provider}}", - "move": "Verplaatsen", - "select": "Selecteren", "continue": "Voortzetten", "discard-changes": "Wijzigingen negeren", "download": "Downloaden", @@ -291,7 +289,6 @@ "enable": "OAuth2-instellingen inschakelen", "domains": "Domeinen", "mobile-apps": "Mobiele applicaties", - "no-mobile-apps": "Geen applicaties geconfigureerd", "mobile-package": "Applicatie pakket", "mobile-package-placeholder": "Vb.: my.example.app", "mobile-package-hint": "Voor Android: uw eigen unieke applicatie-ID. Voor iOS: ID van productbundel.", @@ -299,7 +296,6 @@ "mobile-app-secret": "Geheim van de toepassing", "invalid-mobile-app-secret": "Het geheim van de toepassing mag alleen alfanumerieke tekens bevatten en moet tussen de 16 en 2048 tekens lang zijn.", "copy-mobile-app-secret": "Toepassingsgeheim kopiëren", - "add-mobile-app": "Applicatie toevoegen", "delete-mobile-app": "Toepassingsgegevens verwijderen", "providers": "Providers", "platform-web": "Web", @@ -627,8 +623,6 @@ "filter-type-entity-view-search-query": "Zoekquery voor entiteitsweergave", "filter-type-entity-view-search-query-description": "Entiteitsweergaven met types {{entityViewTypes}} die {{relationType}} relatie hebben {{direction}} {{rootEntity}}", "filter-type-apiUsageState": "Api-gebruiksstatus", - "filter-type-edge-search-query": "Edge-zoekopdracht", - "filter-type-edge-search-query-description": "Edge met types {{edgeTypes}} die {{relationType}} relatie hebben {{direction}} {{rootEntity}}", "entity-filter": "Entiteit filteren", "resolve-multiple": "Oplossen als meerdere entiteiten", "resolve-multiple-hint": "Inschakelen om gegevens van alle gefilterde entiteiten tegelijk weer te geven. \nAls uitgeschakeld, toont de widget alleen gegevens van de geselecteerde entiteit.", @@ -727,7 +721,6 @@ "asset-required": "Asset is vereist", "name-starts-with": "Expressie van itemnaam", "help-text": "Gebruik '%' naar behoefte: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", - "search": "Assets zoeken", "select-group-to-add": "Selecteer doelgroep om geselecteerde assets toe te voegen", "select-group-to-move": "Selecteer de doelgroep om geselecteerde assets te verplaatsen", "remove-assets-from-group": "Weet je zeker dat je { count, plural, =1 {1 asset} other {# assets} } uit groep '{{entityGroup}}' wilt verwijderen?", @@ -804,7 +797,6 @@ "email-messages": "E-mailberichten", "email-messages-daily-activity": "Dagelijkse activiteit e-mailberichten", "email-messages-monthly-activity": "Maandelijkse activiteit e-mailberichten", - "exceptions": "Uitzonderingen", "executions": "Executies", "javascript": "JavaScript", "javascript-executions": "JavaScript-uitvoeringen", @@ -820,9 +812,7 @@ "permanent-failures": "${entityName} Permanente storingen", "permanent-timeouts": "Permanente time-outs van $ {entityName}", "processing-failures": "${entityName} Verwerkingsfouten", - "processing-failures-and-timeouts": "Verwerkingsfouten en time-outs", "processing-timeouts": "${entityName} time-outs voor verwerking", - "queue-stats": "Queue Statistieken", "rule-chain": "Rule chain", "rule-engine": "Rule engine", "rule-engine-daily-activity": "Dagelijkse activiteit van de rule engine", @@ -969,7 +959,7 @@ "name": "Naam", "name-required": "Naam is verplicht.", "name-max-length": "Naam moet kleiner zijn dan 256 tekens", - "description": "Omschrijving: __________", + "description": "Omschrijving", "decoder": "Decoder", "encoder": "Coderingsprogramma", "test-decoder-fuction": "Test decoder functie", @@ -1047,7 +1037,6 @@ "manage-customer-assets": "Klant devices beheren", "manage-public-assets": "Openbare asset beheren", "manage-customer-edges": "Beheer klant edges", - "manage-public-assets": "Openbare asset beheren", "add-customer-text": "Nieuwe klant toevoegen", "no-customers-text": "Geen klanten gevonden", "customer-details": "Klantgegevens", @@ -2451,7 +2440,7 @@ "name": "Naam", "name-required": "Naam is verplicht.", "name-max-length": "Naam moet kleiner zijn dan 256 tekens", - "description": "Omschrijving: __________", + "description": "Omschrijving", "add": "Entiteitsgroep toevoegen", "open-entity-group": "Entiteitsgroep openen", "add-entity-group-text": "Nieuwe entiteitsgroep toevoegen", @@ -2655,8 +2644,6 @@ "assign-entity-views": "Entiteitsweergaven toewijzen", "assign-entity-views-text": "Wijs { count, plural, =1 {1 entity view} other {# entity views} } toe aan de klant", "delete-entity-views": "Entiteitsweergaven verwijderen", - "make-public": "Entiteitsweergave openbaar maken", - "make-private": "Entiteitsweergave privé maken", "unassign-from-customer": "Toewijzing van klant ongedaan maken", "unassign-entity-views": "Toewijzing van entiteitsweergaven intrekken", "unassign-entity-views-action-title": "Toewijzing { count, plural, =1 {1 entity view} other {# entity views} } van klant ongedaan maken", @@ -2666,10 +2653,6 @@ "delete-entity-views-title": "Weet u zeker dat u { count, plural, =1 {1 entity view} other {# entity views} } wilt verwijderen?", "delete-entity-views-action-title": "Verwijder { count, plural, =1 {1 entity view} other {# entity views} }", "delete-entity-views-text": "Opgelet, na de bevestiging worden alle geselecteerde entiteitsweergaven verwijderd en kunnen alle gerelateerde gegevens niet meer worden hersteld.", - "make-public-entity-view-title": "Weet u zeker dat u de entiteitsweergave '{{entityViewName}}' openbaar wilt maken?", - "make-public-entity-view-text": "Na de bevestiging worden de entiteitsweergave en al haar gegevens openbaar en toegankelijk gemaakt voor anderen.", - "make-private-entity-view-title": "Weet u zeker dat u de entiteitsweergave '{{entityViewName}}' privé wilt maken?", - "make-private-entity-view-text": "Na de bevestiging worden de entiteitsweergave en al zijn gegevens privé gemaakt en zijn ze niet toegankelijk voor anderen.", "unassign-entity-view-title": "Weet u zeker dat u de toewijzing van de entiteitsweergave '{{entityViewName}}' wilt opheffen?", "unassign-entity-view-text": "Na de bevestiging wordt de toewijzing van de entiteitsweergave ongedaan gemaakt en is deze niet toegankelijk voor de klant.", "unassign-entity-view": "Toewijzing van entiteitsweergave ongedaan maken", @@ -2760,7 +2743,6 @@ "type": "Type", "in": "In", "out": "Buiten", - "metadata": "Metagegevens", "message": "Bericht", "entity": "Entiteit", "message-id": "Bericht-ID", @@ -3319,7 +3301,7 @@ "name": "Naam", "name-required": "Naam is verplicht.", "name-max-length": "Naam moet kleiner zijn dan 256 tekens", - "description": "Omschrijving: __________", + "description": "Omschrijving", "base-url": "Basis-URL", "base-url-required": "Basis-URL is vereist", "security-key": "Beveiligingssleutel", @@ -4596,7 +4578,7 @@ "name": "Naam", "name-required": "Naam is verplicht.", "name-max-length": "Naam moet kleiner zijn dan 256 tekens", - "description": "Omschrijving: __________", + "description": "Omschrijving", "events": "Events", "details": "Details", "copyId": "Rol-ID kopiëren", @@ -4641,7 +4623,7 @@ "permissions-required": "Er moet ten minste één machtigingsvermelding worden opgegeven.", "remove-permission": "Machtigingsinvoer verwijderen", "add-permission": "Machtigingsinvoer toevoegen", - "other": "Anders __________", + "other": "Anders", "resource": { "resource": "Hulpbron", "select-resource": "Bron selecteren", @@ -5781,12 +5763,6 @@ "delete-solution-text": "Opgelet, na de bevestiging worden de oplossing en alle gerelateerde gegevens onherstelbaar.", "installing": "Oplossingssjabloon installeren..." }, - "markdown": { - "edit": "Bewerken", - "preview": "Voorbeeld", - "copy-code": "Klik om te kopiëren", - "copied": "Gekopieerd!" - }, "white-labeling": { "white-labeling": "White labelling", "white-labeling-general": "Algemene White Labeling", From 3066d172944a72cf717f52ada4a2f5640a04b6c1 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Thu, 1 May 2025 08:47:12 +0300 Subject: [PATCH 10/38] UI: Hint for dynamic settings and refactoring flow animation --- .../bottom-right-elbow-connector-hp.svg | 18 +- .../scada_symbols/bottom-tee-connector-hp.svg | 16 +- .../scada_symbols/cross-connector-hp.svg | 16 +- .../scada_symbols/horizontal-connector-hp.svg | 19 +- .../left-bottom-elbow-connector-hp.svg | 23 ++- .../scada_symbols/left-tee-connector-hp.svg | 18 +- .../left-top-elbow-connector-hp.svg | 18 +- .../long-horizontal-connector-hp.svg | 25 ++- .../long-vertical-connector-hp.svg | 22 ++- .../scada_symbols/right-tee-connector-hp.svg | 16 +- .../top-right-elbow-connector-hp.svg | 18 +- .../scada_symbols/top-tee-connector-hp.svg | 16 +- .../scada_symbols/vertical-connector-hp.svg | 22 ++- .../widget/lib/scada/scada-symbol.models.ts | 171 ++++++++++++++++++ ...dynamic-form-property-panel.component.html | 6 + .../dynamic-form-property-panel.component.ts | 1 + .../dynamic-form/dynamic-form.component.html | 10 +- .../app/shared/models/dynamic-form.models.ts | 3 + .../assets/locale/locale.constant-en_US.json | 1 + 19 files changed, 357 insertions(+), 82 deletions(-) diff --git a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg index fa273dc8ec..b76f7399cc 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Bottom right elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `-${dashWidth + (dashGap || dashWidth)}` : `${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 200,100 L 125,100 Q 100,100 100,125 L 100, 200';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n", "tags": [ { "tag": "line", @@ -140,13 +140,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -155,21 +156,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -182,6 +185,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -200,6 +204,7 @@ { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -215,6 +220,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -250,5 +256,5 @@ } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg index 2feb95e9ff..28e0788d35 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Bottom tee connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst rightLine = \"M100 100H200\";\nconst bottomLine = \"M 100,200 V 103\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst leftLineReversed = \"M 100,100 H 0\";\nconst rightLine = \"M100 100H200\";\nconst rightLineReversed = \"M 200,100 H 100\";\nconst bottomLine = \"M 100,200 V 103\";\nconst bottomLineReversed = \"M 100,103 V 200\";\n\nprepareFlowAnimation('left', leftLine, leftLineReversed);\nprepareFlowAnimation('right', rightLine, rightLineReversed);\nprepareFlowAnimation('bottom', bottomLine, bottomLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}\n", "tags": [ { "tag": "line", @@ -377,13 +377,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -392,21 +393,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -419,6 +422,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -437,6 +441,7 @@ { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -452,6 +457,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, diff --git a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg index ab6798b48b..480666a589 100644 --- a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Cross connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst topLine = \"M100 97L100 0\";\nconst rightLine = \"M100 100H200\";\nconst bottomLine = \"M 100,200 V 103\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst leftLineReversed = \"M 100,100 H 0\";\nconst topLine = \"M100 97L100 0\";\nconst topLineReversed = \"M 100,0 V 97\";\nconst rightLine = \"M100 100H200\";\nconst rightLineReversed = \"M 200,100 H 100\";\nconst bottomLine = \"M 100,200 V 103\";\nconst bottomLineReversed = \"M 100,103 V 200\";\n\nprepareFlowAnimation('left', leftLine, leftLineReversed);\nprepareFlowAnimation('top', topLine, topLineReversed);\nprepareFlowAnimation('right', rightLine, rightLineReversed);\nprepareFlowAnimation('bottom', bottomLine, bottomLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}", "tags": [ { "tag": "line", @@ -493,13 +493,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -508,21 +509,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -535,6 +538,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -553,6 +557,7 @@ { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -568,6 +573,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg index 74d048e884..230c7b68a5 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Horizontal connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 200,100 H 0';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n\n", "tags": [ { "tag": "arrow", @@ -176,13 +176,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -191,21 +192,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -229,11 +232,14 @@ "name": "{i18n:scada.symbol.flow}", "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#C8DFF7" + "default": "#C8DFF7", + "disabled": false, + "visible": true }, { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -249,6 +255,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, diff --git a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg index fd14834d12..1dbeec5f92 100644 --- a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Left bottom elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 100,200 L 100,125 Q 100,100 75,100 L 0, 100';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n", "tags": [ { "tag": "line", @@ -140,13 +140,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -155,21 +156,24 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "disableOnProperty": "mainLine", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -182,6 +186,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -193,11 +198,14 @@ "name": "{i18n:scada.symbol.flow}", "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#C8DFF7" + "default": "#C8DFF7", + "disabled": false, + "visible": true }, { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -213,6 +221,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -248,5 +257,5 @@ } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg index af83a4abb3..82b8babcf7 100644 --- a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Left tee connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H97\";\nconst topLine = \"M100 100L100 0\";\nconst bottomLine = \"M 100,200 V 100\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H97\";\nconst leftLineReversed = \"M 97,100 H 0\";\nconst topLine = \"M100 100L100 0\";\nconst topLineReversed = \"M 100,0 V 100\";\nconst bottomLine = \"M 100,200 V 100\";\nconst bottomLineReversed = \"M 100,100 V 200\";\n\nprepareFlowAnimation('left', leftLine, leftLineReversed);\nprepareFlowAnimation('top', topLine, topLineReversed);\nprepareFlowAnimation('bottom', bottomLine, bottomLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}", "tags": [ { "tag": "line", @@ -377,13 +377,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -392,21 +393,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -419,6 +422,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -437,6 +441,7 @@ { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -452,6 +457,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -487,5 +493,5 @@ } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg index 5b6d30ab65..3c811a2372 100644 --- a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Left top elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 100,0 L 100,75 Q 100,100 75,100 L 0, 100';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n", "tags": [ { "tag": "line", @@ -140,13 +140,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -155,21 +156,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -182,6 +185,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -200,6 +204,7 @@ { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -215,6 +220,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -250,5 +256,5 @@ } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg index 3e65bd6b04..3a3296544d 100644 --- a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg @@ -1,10 +1,9 @@ - - { +<svg xmlns="http://www.w3.org/2000/svg" xmlns:tb="https://thingsboard.io/svg" width="400" height="200" fill="none" version="1.1" viewBox="0 0 400 200"><tb:metadata xmlns=""><![CDATA[{ "title": "HP Long horizontal connector", "description": "Long horizontal connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 2, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n", + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 400,100 H 0';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n", "tags": [ { "tag": "arrow", @@ -177,27 +176,30 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -206,7 +208,7 @@ }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -219,6 +221,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -230,11 +233,14 @@ "name": "{i18n:scada.symbol.flow}", "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#C8DFF7" + "default": "#C8DFF7", + "disabled": false, + "visible": true }, { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -250,6 +256,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -285,5 +292,5 @@ } ] } - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg index b5c9730842..e66fd62c2e 100644 --- a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Long vertical connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 2, - "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 100,0 V 400';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n\n", "tags": [ { "tag": "arrow", @@ -176,13 +176,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -191,21 +192,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -218,6 +221,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -229,11 +233,14 @@ "name": "{i18n:scada.symbol.flow}", "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#C8DFF7" + "default": "#C8DFF7", + "disabled": false, + "visible": true }, { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -249,6 +256,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -284,5 +292,5 @@ } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg index 62aecb065d..e40936c152 100644 --- a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Right tee connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst topLine = \"M100 100L100 0\";\nconst rightLine = \"M103 100H200\";\nconst bottomLine = \"M 100,200 V 100\";\n\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst topLine = \"M100 100L100 0\";\nconst topLineReversed = \"M 100,0 V 100\";\nconst rightLine = \"M103 100H200\";\nconst rightLineReversed = \"M 200,100 H 103\";\nconst bottomLine = \"M 100,200 V 100\";\nconst bottomLineReversed = \"M 100,100 V 200\";\n\nprepareFlowAnimation('top', topLine, topLineReversed);\nprepareFlowAnimation('right', rightLine, rightLineReversed);\nprepareFlowAnimation('bottom', bottomLine, bottomLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}", "tags": [ { "tag": "line", @@ -377,13 +377,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -392,21 +393,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -419,6 +422,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -437,6 +441,7 @@ { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -452,6 +457,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, diff --git a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg index 8ec4e3cc65..8c5224c9f8 100644 --- a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Top right elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `-${dashWidth + (dashGap || dashWidth)}` : `${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 200,100 L 125,100 Q 100,100 100,75 L 100, 0';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n", "tags": [ { "tag": "line", @@ -140,13 +140,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -155,21 +156,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -182,6 +185,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -200,6 +204,7 @@ { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -215,6 +220,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -250,5 +256,5 @@ } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg index e4561a8347..101799fd4b 100644 --- a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Top tee connector", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst topLine = \"M100 97L100 0\";\nconst rightLine = \"M100 100H200\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst leftLineReversed = \"M 100,100 H 0\";\nconst topLine = \"M100 97L100 0\";\nconst topLineReversed = \"M 100,0 V 97\";\nconst rightLine = \"M100 100H200\";\nconst rightLineReversed = \"M 200,100 H 100\";\n\nprepareFlowAnimation('left', leftLine, leftLineReversed);\nprepareFlowAnimation('top', topLine, topLineReversed);\nprepareFlowAnimation('right', rightLine, rightLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}", "tags": [ { "tag": "line", @@ -377,13 +377,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -392,21 +393,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -419,6 +422,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -437,6 +441,7 @@ { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -452,6 +457,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, diff --git a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg index cfaf6793ea..698a910f8d 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg @@ -3,7 +3,7 @@ "description": "Vertical connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 1, - "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 100,0 V 200';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n", "tags": [ { "tag": "arrow", @@ -176,13 +176,14 @@ }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 6, "required": true, "subLabel": "Main", "divider": true, "fieldSuffix": "px", + "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -191,21 +192,23 @@ }, { "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.line}", + "name": "{i18n:scada.symbol.main-line}", "type": "number", "default": 2, "required": true, "subLabel": "Secondary", + "divider": true, "fieldSuffix": "px", + "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, - "visible": true + "visible": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.line-color}", + "name": "{i18n:scada.symbol.main-line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -218,6 +221,7 @@ "type": "number", "default": 4, "subLabel": "Width", + "divider": true, "fieldSuffix": "px", "min": 1, "step": 1, @@ -229,11 +233,14 @@ "name": "{i18n:scada.symbol.flow}", "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#C8DFF7" + "default": "#C8DFF7", + "disabled": false, + "visible": true }, { "id": "flowStyleDash", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -249,6 +256,7 @@ { "id": "flowStyleGap", "name": "{i18n:scada.symbol.flow-style}", + "hint": "{i18n:scada.symbol.flow-style-hint}", "group": "{i18n:scada.symbol.animation}", "type": "number", "default": 10, @@ -284,5 +292,5 @@ } ] }]]> - + \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts index abe570fa3c..ee63dc59a6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts @@ -83,6 +83,10 @@ export interface ScadaSymbolApi { cssAnimation: (element: Element) => ScadaSymbolAnimation | undefined; resetCssAnimation: (element: Element) => void; finishCssAnimation: (element: Element) => void; + connectorAnimation:(element: Element) => ConnectorScadaSymbolAnimation | undefined; + connectorAnimate:(element: Element) => ConnectorScadaSymbolAnimation; + resetConnectorAnimation: (element: Element) => void; + finishConnectorAnimation: (element: Element) => void; disable: (element: Element | Element[]) => void; enable: (element: Element | Element[]) => void; callAction: (event: Event, behaviorId: string, value?: any, observer?: Partial>) => void; @@ -186,6 +190,8 @@ const tbNamespaceRegex = //gm const tbTagRegex = /tb:tag="([^"]*)"/gms; +let syncTime = Date.now(); + const generateElementId = () => { const id = guid(); const firstChar = id.charAt(0); @@ -485,6 +491,7 @@ export class ScadaSymbolObject { private settings: ScadaSymbolObjectSettings; private context: ScadaSymbolContext; private cssAnimations: CssScadaSymbolAnimations; + private connectorAnimations: ScadaSymbolFlowConnectorAnimations; private svgShape: Svg; private box: Box; @@ -604,6 +611,7 @@ export class ScadaSymbolObject { private init() { this.cssAnimations = new CssScadaSymbolAnimations(this.svgShape, this.raf); + this.connectorAnimations = new ScadaSymbolFlowConnectorAnimations(); this.context = { api: { generateElementId: () => generateElementId(), @@ -615,6 +623,10 @@ export class ScadaSymbolObject { cssAnimation: this.cssAnimation.bind(this), resetCssAnimation: this.resetCssAnimation.bind(this), finishCssAnimation: this.finishCssAnimation.bind(this), + connectorAnimation: this.connectorAnimation.bind(this), + connectorAnimate: this.connectorAnimate.bind(this), + resetConnectorAnimation: this.resetConnectorAnimation.bind(this), + finishConnectorAnimation: this.finishConnectorAnimation.bind(this), disable: this.disableElement.bind(this), enable: this.enableElement.bind(this), callAction: this.callAction.bind(this), @@ -959,6 +971,22 @@ export class ScadaSymbolObject { this.cssAnimations.finishAnimation(element); } + private connectorAnimate(element: Element, path: string, reversedPath: string): ConnectorScadaSymbolAnimation { + return this.connectorAnimations.animate(element, path, reversedPath); + } + + private connectorAnimation(element: Element): ConnectorScadaSymbolAnimation | undefined { + return this.connectorAnimations.animation(element); + } + + private resetConnectorAnimation(element: Element) { + this.connectorAnimations.resetAnimation(element); + } + + private finishConnectorAnimation(element: Element) { + this.connectorAnimations.finishAnimation(element); + } + private disableElement(e: Element | Element[]) { this.elements(e).forEach(element => { element.attr({'pointer-events': 'none'}); @@ -1108,6 +1136,20 @@ interface ScadaSymbolAnimation { } +const scadaSymbolConnectorFlowAnimationId = 'scadaSymbolConnectorFlowAnimation'; + +type StrokeLineCap = 'butt' | 'round '| 'square'; + +interface ConnectorScadaSymbolAnimation { + play(): void; + stop(): void; + finish(): void; + + flowAppearance(width: number, color: string, lineCap: StrokeLineCap, dashWidth: number, dashGap: number): ConnectorScadaSymbolAnimation; + duration(speed: number): ConnectorScadaSymbolAnimation; + direction(direction: boolean): ConnectorScadaSymbolAnimation; +} + class CssScadaSymbolAnimations { constructor(private svgShape: Svg, private raf: RafService) {} @@ -1159,6 +1201,135 @@ class CssScadaSymbolAnimations { } } +class ScadaSymbolFlowConnectorAnimations { + constructor() {} + + public animate(element: Element, path = '', reversedPath = ''): ConnectorScadaSymbolAnimation { + this.checkOldAnimation(element); + return this.setupAnimation(element, this.createAnimation(element, path, reversedPath)); + } + + public animation(element: Element): ConnectorScadaSymbolAnimation | undefined { + return element.remember(scadaSymbolConnectorFlowAnimationId); + } + + public resetAnimation(element: Element) { + const animation: ConnectorScadaSymbolAnimation = element.remember(scadaSymbolConnectorFlowAnimationId); + if (animation) { + animation.stop(); + element.remember(scadaSymbolConnectorFlowAnimationId, null); + } + } + + public finishAnimation(element: Element) { + const animation: ConnectorScadaSymbolAnimation = element.remember(scadaSymbolConnectorFlowAnimationId); + if (animation) { + animation.finish(); + element.remember(scadaSymbolConnectorFlowAnimationId, null); + } + } + + private setupAnimation(element: Element, animation: ConnectorScadaSymbolAnimation): ConnectorScadaSymbolAnimation { + element.remember(scadaSymbolConnectorFlowAnimationId, animation); + return animation; + } + + private checkOldAnimation(element: Element) { + const previousAnimation: ConnectorScadaSymbolAnimation = element.remember(scadaSymbolConnectorFlowAnimationId); + if (previousAnimation) { + previousAnimation.finish(); + } + } + + private createAnimation(element: Element, path: string, reversedPath: string): ConnectorScadaSymbolAnimation { + return new FlowConnectorAnimation(element, path, reversedPath); + } +} + +class FlowConnectorAnimation implements ConnectorScadaSymbolAnimation { + + private readonly _path: string; + private readonly _reversedPath: string; + private readonly _animation: Element; + + private _duration: number = 1; + private _lineColor: string = '#C8DFF7'; + private _lineWidth: number = 4; + private _strokeLineCap: StrokeLineCap = 'butt'; + private _dashWidth: number = 10; + private _dashGap: number = 10; + private _direction: boolean = true; + + constructor(private element: Element, + path: string, + pathReversed: string) { + this._path = path; + this._reversedPath = pathReversed; + + const dashArray = `${this._dashWidth} ${this._dashGap}`; + const values = `${this._dashWidth + this._dashGap};0`; + + this._animation = SVG( + `` + + `` + ); + } + + public play() { + if (!this.element.node.childElementCount) { + this.element.add(this._animation); + } + if (!syncTime) { + syncTime = Date.now(); + } + const animateElement = this.element.node.getElementsByTagName('animate')[0]; + const offset = ((Date.now() - syncTime) % 1000) * -1; + (animateElement as SVGAnimationElement).beginElementAt(offset); + } + + public stop() { + const animateElement = this.element.node.getElementsByTagName('animate')[0]; + (animateElement as SVGAnimationElement)?.endElement(); + } + + public finish() { + this.element.findOne('path')?.remove(); + } + + public flowAppearance(width: number, color: string, linecap: StrokeLineCap, dashWidth: number, dashGap: number): this { + const totalLength = (this._animation.node as SVGPathElement).getTotalLength(); + let offset = 0; + if ((totalLength % 100) !== 0) { + const clientWidth = totalLength < 100 ? 100 : this.element.node.ownerSVGElement.clientWidth; + const clientWidthDash = clientWidth / (dashWidth + dashGap); + const totalLengthDash = totalLength / clientWidthDash; + offset = ((dashWidth + dashGap) - totalLengthDash) / 2; + } + this._lineColor = color; + this._lineWidth = width; + this._strokeLineCap = linecap; + this._dashWidth = dashWidth - offset; + this._dashGap = dashGap - offset; + const dashArray = `${this._dashWidth}${this._dashGap ? ` ${this._dashGap}` : ''}`; + const values = `${this._dashWidth + (this._dashGap || this._dashWidth)};0`; + this._animation.stroke({width, color, linecap, dasharray: dashArray}); + this._animation.findOne('animate').attr('values', values); + return this; + } + + public duration(speed: number): this { + this._duration = speed; + this._animation.findOne('animate').attr('dur', `${speed}s`); + return this; + } + + public direction(direction: boolean): this { + this._direction = direction; + this._animation.attr('d', direction ? this._path : this._reversedPath); + return this; + } +} + interface ScadaSymbolAnimationKeyframe { stop: string; style: any; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html index b32c7b5ab1..af59457abe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html @@ -30,6 +30,12 @@ +
+
scada.behavior.hint
+ + + +
dynamic-form.property.group-title
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.ts index 2df62e1259..00a614636d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.ts @@ -104,6 +104,7 @@ export class DynamicFormPropertyPanelComponent implements OnInit { { id: [this.property.id, [Validators.required]], name: [this.property.name, [Validators.required]], + hint: [this.property.hint, []], group: [this.property.group, []], type: [this.property.type, [Validators.required]], arrayItemType: [this.property.arrayItemType, [Validators.required]], diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.html index a64c178eb6..449af2b240 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.html @@ -91,9 +91,15 @@
- {{ propertyRow.label | customTranslate }} +
+ {{ propertyRow.label | customTranslate }} +
-
{{ propertyRow.label | customTranslate }}
+
+
+ {{ propertyRow.label | customTranslate }} +
+
diff --git a/ui-ngx/src/app/shared/models/dynamic-form.models.ts b/ui-ngx/src/app/shared/models/dynamic-form.models.ts index 4132eeff94..b1cc83dbd7 100644 --- a/ui-ngx/src/app/shared/models/dynamic-form.models.ts +++ b/ui-ngx/src/app/shared/models/dynamic-form.models.ts @@ -87,6 +87,7 @@ export type PropertyConditionFunction = (property: FormProperty, model: any) => export interface FormPropertyBase { id: string; name: string; + hint?: string; group?: string; type: FormPropertyType; default: any; @@ -237,6 +238,7 @@ export interface FormPropertyContainerBase { } export interface FormPropertyRow extends FormPropertyContainerBase { + hint?: string; properties?: FormProperty[]; switch?: FormProperty; rowClass?: string; @@ -362,6 +364,7 @@ const toPropertyContainers = (properties: FormProperty[], if (!propertyRow) { propertyRow = { label: property.name, + hint: property.hint, type: FormPropertyContainerType.row, properties: [], rowClass: property.rowClass, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 72afcfc0b9..a3ba654258 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3446,6 +3446,7 @@ "flow-animation-hint": "Indicates whether animation is present in connector.", "flow": "Flow", "flow-style": "Flow style", + "flow-style-hint": "Set the Dash and Gap values so that their sum is divisible by 100 without a remainder for perfect animation synchronization.", "flow-dash-cap": "Flow dash cap", "dash-cap-butt": "Butt", "dash-cap-round": "Round", From 166475a5246fd669726e9b306e9a9885d21f16eb Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Thu, 1 May 2025 16:21:14 +0300 Subject: [PATCH 11/38] UI: Refactoring connector settings --- .../bottom-right-elbow-connector-hp.svg | 65 +++------- .../scada_symbols/bottom-tee-connector-hp.svg | 73 ++++------- .../scada_symbols/cross-connector-hp.svg | 74 ++++------- .../scada_symbols/horizontal-connector-hp.svg | 111 +++++++---------- .../left-bottom-elbow-connector-hp.svg | 74 ++++------- .../scada_symbols/left-tee-connector-hp.svg | 73 ++++------- .../left-top-elbow-connector-hp.svg | 65 +++------- .../long-horizontal-connector-hp.svg | 113 +++++++---------- .../long-vertical-connector-hp.svg | 117 +++++++----------- .../scada_symbols/right-tee-connector-hp.svg | 73 ++++------- .../top-right-elbow-connector-hp.svg | 65 +++------- .../scada_symbols/top-tee-connector-hp.svg | 73 ++++------- .../scada_symbols/vertical-connector-hp.svg | 113 +++++++---------- .../widget/lib/scada/scada-symbol.models.ts | 7 +- .../assets/locale/locale.constant-en_US.json | 13 +- 15 files changed, 376 insertions(+), 733 deletions(-) diff --git a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg index b76f7399cc..f7ffd5f167 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null } ], @@ -53,8 +53,8 @@ }, { "id": "animationDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", @@ -131,48 +131,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -180,12 +154,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -194,8 +167,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -203,14 +176,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -219,14 +192,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -234,7 +207,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg index 28e0788d35..bdbbdca81f 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null }, { @@ -58,8 +58,8 @@ }, { "id": "leftFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.left-connector}", "type": "value", "valueType": "BOOLEAN", @@ -174,8 +174,8 @@ }, { "id": "rightFlowDirection", - "name": "{i18n:scada.symbol.flow-direction}", - "hint": "{i18n:scada.symbol.flow-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.right-connector}", "type": "value", "valueType": "BOOLEAN", @@ -290,8 +290,8 @@ }, { "id": "bottomFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.bottom-connector}", "type": "value", "valueType": "BOOLEAN", @@ -368,48 +368,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -417,12 +391,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -431,8 +404,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -440,14 +413,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -456,14 +429,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -471,7 +444,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg index 480666a589..db6b091e0e 100644 --- a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null }, { @@ -58,8 +58,8 @@ }, { "id": "leftFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.left-connector}", "type": "value", "valueType": "BOOLEAN", @@ -174,8 +174,8 @@ }, { "id": "topFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.top-connector}", "type": "value", "valueType": "BOOLEAN", @@ -290,8 +290,8 @@ }, { "id": "rightFlowDirection", - "name": "{i18n:scada.symbol.flow-direction}", - "hint": "{i18n:scada.symbol.flow-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.right-connector}", "type": "value", "valueType": "BOOLEAN", @@ -406,8 +406,8 @@ }, { "id": "bottomFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.bottom-connector}", "type": "value", "valueType": "BOOLEAN", @@ -483,17 +483,9 @@ } ], "properties": [ - { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, { "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, @@ -504,28 +496,11 @@ "min": 0, "max": 99, "step": 1, - "disabled": false, - "visible": true - }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false + "disabled": false }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -533,12 +508,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -547,8 +521,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -556,14 +530,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -572,14 +546,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -587,7 +561,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg index 230c7b68a5..93ef81f57b 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg @@ -12,7 +12,7 @@ }, { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null } ], @@ -53,18 +53,18 @@ "defaultWidgetActionSettings": null }, { - "id": "arrowDirection", - "name": "{i18n:scada.symbol.arrow-direction}", - "hint": "{i18n:scada.symbol.arrow-direction-hint}", + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", - "trueLabel": "{i18n:scada.symbol.forward}", - "falseLabel": "{i18n:scada.symbol.reverse}", - "stateLabel": "{i18n:scada.symbol.forward}", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", "defaultGetValueSettings": { "action": "DO_NOTHING", - "defaultValue": true, + "defaultValue": false, "executeRpc": { "method": "getState", "requestTimeout": 5000, @@ -72,34 +72,38 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "scope": null, - "key": "state" + "key": "state", + "scope": null }, "getTimeSeries": { "key": "state" }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, "dataToValue": { "type": "NONE", - "dataToValueFunction": "/* Should return boolean value */\nreturn data;", - "compareToValue": true + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" } }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null }, { - "id": "flowAnimation", - "name": "{i18n:scada.symbol.flow-animation}", - "hint": "{i18n:scada.symbol.flow-animation-hint}", + "id": "arrowDirection", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", - "trueLabel": "{i18n:scada.symbol.present}", - "falseLabel": "{i18n:scada.symbol.absent}", - "stateLabel": "{i18n:scada.symbol.flow-present}", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", "defaultGetValueSettings": { "action": "DO_NOTHING", - "defaultValue": false, + "defaultValue": true, "executeRpc": { "method": "getState", "requestTimeout": 5000, @@ -107,20 +111,16 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "key": "state", - "scope": null + "scope": null, + "key": "state" }, "getTimeSeries": { "key": "state" }, - "getAlarmStatus": { - "severityList": null, - "typeList": null - }, "dataToValue": { "type": "NONE", - "compareToValue": true, - "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true } }, "defaultSetValueSettings": null, @@ -167,48 +167,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -216,11 +190,10 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", "fieldSuffix": "px", "min": 1, "step": 1, @@ -229,8 +202,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -238,14 +211,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -254,14 +227,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -269,7 +242,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg index 1dbeec5f92..1c24cca2e0 100644 --- a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null } ], @@ -32,8 +32,8 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "key": "state", - "scope": null + "scope": "SHARED_SCOPE", + "key": "flow" }, "getTimeSeries": { "key": "state" @@ -44,8 +44,8 @@ }, "dataToValue": { "type": "NONE", - "compareToValue": true, - "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true } }, "defaultSetValueSettings": null, @@ -53,8 +53,8 @@ }, { "id": "animationDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", @@ -131,49 +131,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "disableOnProperty": "mainLine", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -181,12 +154,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -195,8 +167,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -204,14 +176,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -220,14 +192,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -235,7 +207,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg index 82b8babcf7..461e91fe94 100644 --- a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null }, { @@ -58,8 +58,8 @@ }, { "id": "leftFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.left-connector}", "type": "value", "valueType": "BOOLEAN", @@ -174,8 +174,8 @@ }, { "id": "topFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.top-connector}", "type": "value", "valueType": "BOOLEAN", @@ -290,8 +290,8 @@ }, { "id": "bottomFlowDirection", - "name": "{i18n:scada.symbol.flow-direction}", - "hint": "{i18n:scada.symbol.flow-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.bottom-connector}", "type": "value", "valueType": "BOOLEAN", @@ -368,48 +368,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -417,12 +391,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -431,8 +404,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -440,14 +413,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -456,14 +429,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -471,7 +444,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg index 3c811a2372..19e217a428 100644 --- a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null } ], @@ -53,8 +53,8 @@ }, { "id": "animationDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", @@ -131,48 +131,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -180,12 +154,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -194,8 +167,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -203,14 +176,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -219,14 +192,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -234,7 +207,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg index 3a3296544d..0fe805c9a7 100644 --- a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg @@ -12,7 +12,7 @@ }, { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null } ], @@ -53,18 +53,18 @@ "defaultWidgetActionSettings": null }, { - "id": "arrowDirection", - "name": "{i18n:scada.symbol.arrow-direction}", - "hint": "{i18n:scada.symbol.arrow-direction-hint}", + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", - "trueLabel": "{i18n:scada.symbol.forward}", - "falseLabel": "{i18n:scada.symbol.reverse}", - "stateLabel": "{i18n:scada.symbol.forward}", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", "defaultGetValueSettings": { "action": "DO_NOTHING", - "defaultValue": true, + "defaultValue": false, "executeRpc": { "method": "getState", "requestTimeout": 5000, @@ -72,34 +72,38 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "scope": null, - "key": "state" + "key": "state", + "scope": null }, "getTimeSeries": { "key": "state" }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, "dataToValue": { "type": "NONE", - "dataToValueFunction": "/* Should return boolean value */\nreturn data;", - "compareToValue": true + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" } }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null }, { - "id": "flowAnimation", - "name": "{i18n:scada.symbol.flow-animation}", - "hint": "{i18n:scada.symbol.flow-animation-hint}", + "id": "arrowDirection", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", - "trueLabel": "{i18n:scada.symbol.present}", - "falseLabel": "{i18n:scada.symbol.absent}", - "stateLabel": "{i18n:scada.symbol.flow-present}", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", "defaultGetValueSettings": { "action": "DO_NOTHING", - "defaultValue": false, + "defaultValue": true, "executeRpc": { "method": "getState", "requestTimeout": 5000, @@ -107,20 +111,16 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "key": "state", - "scope": null + "scope": null, + "key": "state" }, "getTimeSeries": { "key": "state" }, - "getAlarmStatus": { - "severityList": null, - "typeList": null - }, "dataToValue": { "type": "NONE", - "compareToValue": true, - "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true } }, "defaultSetValueSettings": null, @@ -167,39 +167,13 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, - "fieldSuffix": "px", - "condition": "return model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return !model.mainLine;", "min": 0, "max": 99, "step": 1, @@ -208,7 +182,7 @@ }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -216,12 +190,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -230,8 +203,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -239,14 +212,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -255,14 +228,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -270,7 +243,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg index e66fd62c2e..411859b5ed 100644 --- a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg @@ -12,7 +12,7 @@ }, { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null } ], @@ -53,18 +53,18 @@ "defaultWidgetActionSettings": null }, { - "id": "arrowDirection", - "name": "{i18n:scada.symbol.arrow-direction}", - "hint": "{i18n:scada.symbol.arrow-direction-hint}", + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", - "trueLabel": "{i18n:scada.symbol.forward}", - "falseLabel": "{i18n:scada.symbol.reverse}", - "stateLabel": "{i18n:scada.symbol.forward}", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", "defaultGetValueSettings": { "action": "DO_NOTHING", - "defaultValue": true, + "defaultValue": false, "executeRpc": { "method": "getState", "requestTimeout": 5000, @@ -72,34 +72,38 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "scope": null, - "key": "state" + "key": "state", + "scope": null }, "getTimeSeries": { "key": "state" }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, "dataToValue": { "type": "NONE", - "dataToValueFunction": "/* Should return boolean value */\nreturn data;", - "compareToValue": true + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" } }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null }, { - "id": "flowAnimation", - "name": "{i18n:scada.symbol.flow-animation}", - "hint": "{i18n:scada.symbol.flow-animation-hint}", + "id": "arrowDirection", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", - "trueLabel": "{i18n:scada.symbol.present}", - "falseLabel": "{i18n:scada.symbol.absent}", - "stateLabel": "{i18n:scada.symbol.flow-present}", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", "defaultGetValueSettings": { "action": "DO_NOTHING", - "defaultValue": false, + "defaultValue": true, "executeRpc": { "method": "getState", "requestTimeout": 5000, @@ -107,20 +111,16 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "key": "state", - "scope": null + "scope": null, + "key": "state" }, "getTimeSeries": { "key": "state" }, - "getAlarmStatus": { - "severityList": null, - "typeList": null - }, "dataToValue": { "type": "NONE", - "compareToValue": true, - "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true } }, "defaultSetValueSettings": null, @@ -167,48 +167,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -216,12 +190,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -230,8 +203,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -239,14 +212,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -255,22 +228,20 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, - "step": 1, - "disabled": false, - "visible": true + "min": 0, + "step": 1 }, { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg index e40936c152..dafcf53647 100644 --- a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null }, { @@ -58,8 +58,8 @@ }, { "id": "topFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.top-connector}", "type": "value", "valueType": "BOOLEAN", @@ -174,8 +174,8 @@ }, { "id": "rightFlowDirection", - "name": "{i18n:scada.symbol.flow-direction}", - "hint": "{i18n:scada.symbol.flow-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.right-connector}", "type": "value", "valueType": "BOOLEAN", @@ -290,8 +290,8 @@ }, { "id": "bottomFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.bottom-connector}", "type": "value", "valueType": "BOOLEAN", @@ -368,48 +368,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -417,12 +391,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -431,8 +404,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -440,14 +413,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -456,14 +429,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -471,7 +444,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg index 8c5224c9f8..f9f2fe20d2 100644 --- a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null } ], @@ -53,8 +53,8 @@ }, { "id": "animationDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", @@ -131,48 +131,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -180,12 +154,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -194,8 +167,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -203,14 +176,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -219,14 +192,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -234,7 +207,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg index 101799fd4b..f61bf85ccd 100644 --- a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null }, { @@ -58,8 +58,8 @@ }, { "id": "leftFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.left-connector}", "type": "value", "valueType": "BOOLEAN", @@ -174,8 +174,8 @@ }, { "id": "topFlowDirection", - "name": "{i18n:scada.symbol.animation-direction}", - "hint": "{i18n:scada.symbol.animation-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.top-connector}", "type": "value", "valueType": "BOOLEAN", @@ -290,8 +290,8 @@ }, { "id": "rightFlowDirection", - "name": "{i18n:scada.symbol.flow-direction}", - "hint": "{i18n:scada.symbol.flow-direction-hint}", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": "{i18n:scada.symbol.right-connector}", "type": "value", "valueType": "BOOLEAN", @@ -368,48 +368,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -417,12 +391,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -431,8 +404,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -440,14 +413,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -456,14 +429,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -471,7 +444,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg index 698a910f8d..9667928e4e 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg @@ -12,7 +12,7 @@ }, { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", "actions": null } ], @@ -53,18 +53,18 @@ "defaultWidgetActionSettings": null }, { - "id": "arrowDirection", - "name": "{i18n:scada.symbol.arrow-direction}", - "hint": "{i18n:scada.symbol.arrow-direction-hint}", + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", - "trueLabel": "{i18n:scada.symbol.forward}", - "falseLabel": "{i18n:scada.symbol.reverse}", - "stateLabel": "{i18n:scada.symbol.forward}", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", "defaultGetValueSettings": { "action": "DO_NOTHING", - "defaultValue": true, + "defaultValue": false, "executeRpc": { "method": "getState", "requestTimeout": 5000, @@ -72,34 +72,38 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "scope": null, - "key": "state" + "key": "state", + "scope": null }, "getTimeSeries": { "key": "state" }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, "dataToValue": { "type": "NONE", - "dataToValueFunction": "/* Should return boolean value */\nreturn data;", - "compareToValue": true + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" } }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null }, { - "id": "flowAnimation", - "name": "{i18n:scada.symbol.flow-animation}", - "hint": "{i18n:scada.symbol.flow-animation-hint}", + "id": "arrowDirection", + "name": "{i18n:scada.symbol.arrow-direction}", + "hint": "{i18n:scada.symbol.arrow-direction-hint}", "group": null, "type": "value", "valueType": "BOOLEAN", - "trueLabel": "{i18n:scada.symbol.present}", - "falseLabel": "{i18n:scada.symbol.absent}", - "stateLabel": "{i18n:scada.symbol.flow-present}", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", "defaultGetValueSettings": { "action": "DO_NOTHING", - "defaultValue": false, + "defaultValue": true, "executeRpc": { "method": "getState", "requestTimeout": 5000, @@ -107,20 +111,16 @@ "persistentPollingInterval": 1000 }, "getAttribute": { - "key": "state", - "scope": null + "scope": null, + "key": "state" }, "getTimeSeries": { "key": "state" }, - "getAlarmStatus": { - "severityList": null, - "typeList": null - }, "dataToValue": { "type": "NONE", - "compareToValue": true, - "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true } }, "defaultSetValueSettings": null, @@ -167,48 +167,22 @@ ], "properties": [ { - "id": "mainLine", - "name": "{i18n:scada.symbol.main-line}", - "type": "switch", - "default": true, - "disabled": false, - "visible": true - }, - { - "id": "mainLineSize", - "name": "{i18n:scada.symbol.main-line}", + "id": "lineSize", + "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, "required": true, - "subLabel": "Main", - "divider": true, + "divider": false, "fieldSuffix": "px", - "condition": "return model.mainLine;", "min": 0, "max": 99, "step": 1, "disabled": false, "visible": true }, - { - "id": "secondaryLineSize", - "name": "{i18n:scada.symbol.main-line}", - "type": "number", - "default": 2, - "required": true, - "subLabel": "Secondary", - "divider": true, - "fieldSuffix": "px", - "condition": "return !model.mainLine;", - "min": 0, - "max": 99, - "step": 1, - "disabled": false, - "visible": false - }, { "id": "lineColor", - "name": "{i18n:scada.symbol.main-line}", + "name": "{i18n:scada.symbol.line}", "type": "color", "default": "#1A1A1A", "disabled": false, @@ -216,12 +190,11 @@ }, { "id": "flowAnimationWidth", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 4, - "subLabel": "Width", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 1, "step": 1, @@ -230,8 +203,8 @@ }, { "id": "flowAnimationColor", - "name": "{i18n:scada.symbol.flow}", - "group": "{i18n:scada.symbol.animation}", + "name": "{i18n:scada.symbol.flow-line}", + "group": "{i18n:scada.symbol.flow}", "type": "color", "default": "#C8DFF7", "disabled": false, @@ -239,14 +212,14 @@ }, { "id": "flowStyleDash", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "required": true, "subLabel": "{i18n:scada.symbol.dash}", - "divider": true, + "divider": false, "fieldSuffix": "px", "min": 0, "step": 1, @@ -255,14 +228,14 @@ }, { "id": "flowStyleGap", - "name": "{i18n:scada.symbol.flow-style}", + "name": "{i18n:scada.symbol.flow-line-style}", "hint": "{i18n:scada.symbol.flow-style-hint}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "number", "default": 10, "subLabel": "{i18n:scada.symbol.gap}", "fieldSuffix": "px", - "min": 1, + "min": 0, "step": 1, "disabled": false, "visible": true @@ -270,7 +243,7 @@ { "id": "flowDashCap", "name": "{i18n:scada.symbol.flow-dash-cap}", - "group": "{i18n:scada.symbol.animation}", + "group": "{i18n:scada.symbol.flow}", "type": "select", "default": "butt", "items": [ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts index ee63dc59a6..9e0d07fed4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts @@ -190,7 +190,7 @@ const tbNamespaceRegex = //gm const tbTagRegex = /tb:tag="([^"]*)"/gms; -let syncTime = Date.now(); +const syncTime = Date.now(); const generateElementId = () => { const id = guid(); @@ -1279,9 +1279,6 @@ class FlowConnectorAnimation implements ConnectorScadaSymbolAnimation { if (!this.element.node.childElementCount) { this.element.add(this._animation); } - if (!syncTime) { - syncTime = Date.now(); - } const animateElement = this.element.node.getElementsByTagName('animate')[0]; const offset = ((Date.now() - syncTime) % 1000) * -1; (animateElement as SVGAnimationElement).beginElementAt(offset); @@ -1310,7 +1307,7 @@ class FlowConnectorAnimation implements ConnectorScadaSymbolAnimation { this._strokeLineCap = linecap; this._dashWidth = dashWidth - offset; this._dashGap = dashGap - offset; - const dashArray = `${this._dashWidth}${this._dashGap ? ` ${this._dashGap}` : ''}`; + const dashArray = `${this._dashWidth} ${this._dashGap}`; const values = `${this._dashWidth + (this._dashGap || this._dashWidth)};0`; this._animation.stroke({width, color, linecap, dasharray: dashArray}); this._animation.findOne('animate').attr('values', values); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index a3ba654258..b9eaf3cb82 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3438,16 +3438,15 @@ "arrow-presence": "Arrow presence", "arrow-presence-hint": "Indicates whether arrow is present in connector.", "arrow-present": "Arrow present", - "arrow-direction": "Arrow/Animation direction", + "arrow-direction": "Flow direction", "arrow-direction-hint": "Indicates flow direction.", - "animation-direction": "Flow animation direction", - "animation-direction-hint": "Indicates animation flow direction.", - "flow-animation": "Flow animation", - "flow-animation-hint": "Indicates whether animation is present in connector.", + "flow-animation": "Flow presence", + "flow-animation-hint": "Indicates whether fluid is flowing in connector.", "flow": "Flow", - "flow-style": "Flow style", + "flow-line": "Line", + "flow-line-style": "Line style", "flow-style-hint": "Set the Dash and Gap values so that their sum is divisible by 100 without a remainder for perfect animation synchronization.", - "flow-dash-cap": "Flow dash cap", + "flow-dash-cap": "Dash cap", "dash-cap-butt": "Butt", "dash-cap-round": "Round", "dash-cap-square": "Square", From 8d749f593b4a14dcb23f959536612ad47c83b65a Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 27 Mar 2025 13:59:19 +0200 Subject: [PATCH 12/38] MQTT client: limit retransmission attempts to prevent unlimited memory usage and network overload --- .../server/actors/ActorSystemContext.java | 7 +- .../actors/ruleChain/DefaultTbContext.java | 9 +- ...ClientRetransmissionSettingsComponent.java | 37 +++ .../mqtt/MqttClientSettingsComponent.java | 47 ++++ .../src/main/resources/thingsboard.yml | 24 ++ .../server/msa/ContainerTestSuite.java | 12 +- .../msa/connectivity/MqttClientTest.java | 15 +- .../connectivity/MqttGatewayClientTest.java | 14 +- netty-mqtt/pom.xml | 20 ++ .../MaxRetransmissionsReachedException.java | 24 ++ .../thingsboard/mqtt/MqttChannelHandler.java | 30 +-- .../java/org/thingsboard/mqtt/MqttClient.java | 2 +- .../thingsboard/mqtt/MqttClientConfig.java | 20 ++ .../org/thingsboard/mqtt/MqttClientImpl.java | 82 ++++++- .../thingsboard/mqtt/MqttConnectResult.java | 3 + .../thingsboard/mqtt/MqttPendingPublish.java | 134 +++++++---- .../mqtt/MqttPendingSubscription.java | 119 ++++++---- .../mqtt/MqttPendingUnsubscription.java | 85 +++++-- .../org/thingsboard/mqtt/MqttPingHandler.java | 17 +- .../thingsboard/mqtt/PendingOperation.java | 4 +- .../mqtt/RetransmissionHandler.java | 101 +++++++-- .../org/thingsboard/mqtt/MqttClientTest.java | 210 ++++++++++++++++++ .../thingsboard/mqtt/MqttPingHandlerTest.java | 63 ------ .../org/thingsboard/mqtt/MqttTestProxy.java | 202 +++++++++++++++++ .../mqtt/integration/MqttIntegrationTest.java | 151 ------------- .../mqtt/integration/server/MqttServer.java | 84 ------- .../server/MqttTransportHandler.java | 141 ------------ .../test/resources/junit-platform.properties | 3 - pom.xml | 6 + .../rule/engine/api/MqttClientSettings.java | 26 +++ .../rule/engine/api/TbContext.java | 5 + .../rule/engine/mqtt/TbMqttNode.java | 8 + .../rule/engine/mqtt/TbMqttNodeTest.java | 18 ++ 33 files changed, 1100 insertions(+), 623 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java create mode 100644 application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java create mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java delete mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java create mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java delete mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java delete mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java delete mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java delete mode 100644 netty-mqtt/src/test/resources/junit-platform.properties create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 1ed919e922..78819ab246 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -30,9 +30,10 @@ import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; -import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; @@ -639,6 +640,10 @@ public class ActorSystemContext { @Getter private long cfCalculationResultTimeout; + @Autowired + @Getter + private MqttClientSettings mqttClientSettings; + @Getter @Setter private TbActorSystem actorSystem; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 033e10ca9a..3fb28aee38 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -23,14 +23,15 @@ import org.bouncycastle.util.Arrays; import org.thingsboard.common.util.DebugModeUtil; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService; import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; -import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.RuleEngineRpcService; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.ScriptEngine; @@ -1010,13 +1011,17 @@ public class DefaultTbContext implements TbContext { return mainCtx.getAuditLogService(); } + @Override + public MqttClientSettings getMqttClientSettings() { + return mainCtx.getMqttClientSettings(); + } + private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) { TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("ruleNodeId", ruleNodeId.toString()); return metaData; } - @Override public void schedule(Runnable runnable, long delay, TimeUnit timeUnit) { mainCtx.getScheduler().schedule(runnable, delay, timeUnit); diff --git a/application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java b/application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java new file mode 100644 index 0000000000..33e9358d2b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config.mqtt; + +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Data +@Validated +@Configuration +@ConfigurationProperties(prefix = "mqtt.client.retransmission") +public class MqttClientRetransmissionSettingsComponent { + + @PositiveOrZero + private int maxAttempts; + @PositiveOrZero + private long initialDelayMillis; + @PositiveOrZero + private double jitterFactor; + +} diff --git a/application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java b/application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java new file mode 100644 index 0000000000..25df212925 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config.mqtt; + +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import org.springframework.context.annotation.Configuration; +import org.thingsboard.rule.engine.api.MqttClientSettings; + +@ToString +@EqualsAndHashCode +@Configuration +@RequiredArgsConstructor +public class MqttClientSettingsComponent implements MqttClientSettings { + + private final MqttClientRetransmissionSettingsComponent retransmissionSettingsComponent; + + @Override + public int getRetransmissionMaxAttempts() { + return retransmissionSettingsComponent.getMaxAttempts(); + } + + @Override + public long getRetransmissionInitialDelayMillis() { + return retransmissionSettingsComponent.getInitialDelayMillis(); + } + + @Override + public double getRetransmissionJitterFactor() { + return retransmissionSettingsComponent.getJitterFactor(); + } + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 721d23b700..d7a55e496e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1920,3 +1920,27 @@ mobileApp: googlePlayLink: "${TB_MOBILE_APP_GOOGLE_PLAY_LINK:https://play.google.com/store/apps/details?id=org.thingsboard.demo.app}" # Link to App Store for Thingsboard Live mobile application appStoreLink: "${TB_MOBILE_APP_APP_STORE_LINK:https://apps.apple.com/us/app/thingsboard-live/id1594355695}" + +mqtt: + # MQTT client configuration parameters + client: + # Parameters that control the retransmission mechanism. + # This mechanism only applies to the handling of MQTT Publish, Subscribe, Unsubscribe and Pubrel messages. + # With the updated default settings: + # - After sending the message, wait approximately 5000 ms (± jitter) for the 1st attempt. + # - The 2nd attempt will occur after roughly 5000 * 2 = 10,000 ms (± jitter). + # - The 3rd attempt will occur after roughly 5000 * 4 = 20,000 ms (± jitter). + # - The 4th "attempt" will not actually perform a retransmission. + # Instead, the system will detect that the maximum number of attempts has been reached and drop the pending message. + retransmission: + # Maximum number of retransmission attempts allowed. + # If the attempt count exceeds this value, retransmissions will stop and the pending message will be dropped. + max_attempts: "${TB_MQTT_CLIENT_RETRANSMISSION_MAX_ATTEMPTS:3}" + # Base delay (in milliseconds) before the first retransmission attempt, measured from the moment the message is sent. + # Subsequent delays are calculated using exponential backoff. + # This base delay is also used as the reference value for applying jitter. + initial_delay_millis: "${TB_MQTT_CLIENT_RETRANSMISSION_INITIAL_DELAY_MILLIS:5000}" + # Jitter factor applied to the calculated retransmission delay. + # The actual delay is randomized within a range defined by multiplying the base delay by a factor between (1 - jitter_factor) and (1 + jitter_factor). + # For example, a jitter_factor of 0.15 means the actual delay may vary by up to ±15% of the base delay. + jitter_factor: "${TB_MQTT_CLIENT_RETRANSMISSION_JITTER_FACTOR:0.15}" diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java index 65def6a964..9b9bb31dc1 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -55,8 +55,8 @@ public class ContainerTestSuite { private static final String TB_JS_EXECUTOR_LOG_REGEXP = ".*template started.*"; private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(400); - private DockerComposeContainer testContainer; - private ThingsBoardDbInstaller installTb; + private DockerComposeContainer testContainer; + private ThingsBoardDbInstaller installTb; private boolean isActive; private static ContainerTestSuite containerTestSuite; @@ -194,7 +194,7 @@ public class ContainerTestSuite { setActive(true); } catch (Exception e) { log.error("Failed to create test container", e); - fail("Failed to create test container"); + fail("Failed to create test container", e); } } @@ -263,7 +263,7 @@ public class ContainerTestSuite { log.info("Trying to delete temp dir {}", targetDir); FileUtils.deleteDirectory(new File(targetDir)); } catch (IOException e) { - log.error("Can't delete temp directory " + targetDir, e); + log.error("Can't delete temp directory {}", targetDir, e); } } @@ -286,8 +286,8 @@ public class ContainerTestSuite { FileUtils.writeStringToFile(file, outputContent, StandardCharsets.UTF_8); assertThat(FileUtils.readFileToString(file, StandardCharsets.UTF_8), is(outputContent)); } catch (IOException e) { - log.error("failed to update file " + sourceFilename, e); - fail("failed to update file"); + log.error("failed to update file {}", sourceFilename, e); + fail("failed to update file", e); } } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index ebdfb4e3c9..dacfea9b10 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -42,7 +42,6 @@ import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientCallback; import org.thingsboard.mqtt.MqttClientConfig; import org.thingsboard.mqtt.MqttHandler; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileProvisionType; @@ -82,7 +81,6 @@ import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.fail; -import static org.thingsboard.server.common.data.DataConstants.DEVICE; import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; @@ -301,7 +299,7 @@ public class MqttClientTest extends AbstractContainerTest { assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}"); - Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length())); + int requestId = Integer.parseInt(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length())); JsonObject clientResponse = new JsonObject(); clientResponse.addProperty("response", "someResponse"); // Send a response to the server's RPC request @@ -340,7 +338,7 @@ public class MqttClientTest extends AbstractContainerTest { assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}"); - Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length())); + int requestId = Integer.parseInt(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length())); JsonObject clientResponse = new JsonObject(); clientResponse.addProperty("response", "someResponse"); // Send a response to the server's RPC request @@ -520,13 +518,13 @@ public class MqttClientTest extends AbstractContainerTest { mqttClient.on("/provision/response", listener, MqttQoS.AT_LEAST_ONCE).get(3 * timeoutMultiplier, TimeUnit.SECONDS); TimeUnit.SECONDS.sleep(2 * timeoutMultiplier); assertThat(subAckResult[0]).isNotNull(); - assertThat(MqttReasonCodes.SubAck.GRANTED_QOS_1.equals(subAckResult[0])); + assertThat(MqttReasonCodes.SubAck.GRANTED_QOS_1).isEqualTo(subAckResult[0]); subAckResult[0] = null; mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE).get(3 * timeoutMultiplier, TimeUnit.SECONDS); TimeUnit.SECONDS.sleep(2 * timeoutMultiplier); assertThat(subAckResult[0]).isNotNull(); - assertThat(MqttReasonCodes.SubAck.TOPIC_FILTER_INVALID.equals(subAckResult[0])); + assertThat(MqttReasonCodes.SubAck.TOPIC_FILTER_INVALID).isEqualTo(subAckResult[0]); testRestClient.deleteDeviceIfExists(device.getId()); updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); @@ -596,7 +594,7 @@ public class MqttClientTest extends AbstractContainerTest { .await() .alias("Check device disconnect.") .atMost(TIMEOUT*timeoutMultiplier, TimeUnit.SECONDS) - .until(() -> returnCodeByteValue.size() > 0); + .until(() -> !returnCodeByteValue.isEmpty()); assertThat(returnCodeByteValueSecondClient).isEmpty(); assertThat(returnCodeByteValue).isNotEmpty(); @@ -663,7 +661,7 @@ public class MqttClientTest extends AbstractContainerTest { .stream() .filter(RuleChain::isRoot) .findFirst(); - if (!defaultRuleChain.isPresent()) { + if (defaultRuleChain.isEmpty()) { fail("Root rule chain wasn't found"); } return defaultRuleChain.get().getId(); @@ -717,6 +715,7 @@ public class MqttClientTest extends AbstractContainerTest { clientConfig.setClientId("MQTT client from test"); clientConfig.setUsername(username); clientConfig.setProtocolVersion(mqttVersion); + clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(5, 5000L, 0.1d)); // same as defaults in thingsboard.yml as of time of this writing MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor); if (connect) { mqttClient.connect(TRANSPORT_HOST, TRANSPORT_PORT).get(); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java index cc587fbbd5..32e8498f45 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java @@ -39,7 +39,6 @@ import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; import org.thingsboard.mqtt.MqttHandler; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DeviceId; @@ -65,7 +64,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; -import static org.thingsboard.server.common.data.DataConstants.DEVICE; import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultGatewayPrototype; @@ -76,7 +74,6 @@ public class MqttGatewayClientTest extends AbstractContainerTest { private MqttClient mqttClient; private Device createdDevice; private MqttMessageListener listener; - private JsonParser jsonParser = new JsonParser(); AbstractListeningExecutor handlerExecutor; @@ -100,7 +97,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { } @AfterMethod - public void removeGateway() { + public void removeGateway() { testRestClient.deleteDeviceIfExists(this.gatewayDevice.getId()); testRestClient.deleteDeviceIfExists(this.createdDevice.getId()); this.listener = null; @@ -197,7 +194,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get(); event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); - JsonObject responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); + JsonObject responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); assertThat(responseData.has("value")).isTrue(); assertThat(responseData.get("value").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString()); @@ -213,7 +210,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get(); event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); - responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); + responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); assertThat(responseData.has("values")).isTrue(); assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString()); @@ -231,7 +228,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get(); event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); - responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); + responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); assertThat(responseData.has("values")).isTrue(); assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString()); @@ -390,7 +387,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(gatewayAttributesRequest.toString().getBytes())).get(); MqttEvent clientAttributeEvent = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); assertThat(clientAttributeEvent).isNotNull(); - JsonObject responseMessage = new JsonParser().parse(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject(); + JsonObject responseMessage = JsonParser.parseString(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject(); assertThat(responseMessage.get("id").getAsInt()).isEqualTo(messageId); assertThat(responseMessage.get("device").getAsString()).isEqualTo(createdDevice.getName()); @@ -427,6 +424,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest { clientConfig.setOwnerId(getOwnerId()); clientConfig.setClientId("MQTT client from test"); clientConfig.setUsername(deviceCredentials.getCredentialsId()); + clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(3, 5000L, 0.1d)); // same as defaults in thingsboard.yml as of time of this writing MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor); mqttClient.connect("localhost", 1883).get(); return mqttClient; diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index b5fd83a54f..f9aa80c78e 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -87,6 +87,26 @@ awaitility test + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + software.xdev + testcontainers-junit4-mock + test + + + org.testcontainers + hivemq + test + diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java new file mode 100644 index 0000000000..3d483dd541 --- /dev/null +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.mqtt; + +public class MaxRetransmissionsReachedException extends RuntimeException { + + public MaxRetransmissionsReachedException(String message) { + super(message); + } + +} diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java index ad976c848a..9686a2b1d7 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java @@ -57,7 +57,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler } @Override - protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception { + protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) { if (msg.decoderResult().isSuccess()) { switch (msg.fixedHeader().messageType()) { case CONNACK: @@ -120,6 +120,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler this.client.getClientConfig().getUsername(), this.client.getClientConfig().getPassword() != null ? this.client.getClientConfig().getPassword().getBytes(CharsetUtil.UTF_8) : null ); + log.debug("{} Sending CONNECT", client.getClientConfig().getOwnerId()); ctx.channel().writeAndFlush(new MqttConnectMessage(fixedHeader, variableHeader, payload)); } @@ -173,6 +174,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler } private void handleConack(Channel channel, MqttConnAckMessage message) { + log.debug("{} Handling CONNACK", client.getClientConfig().getOwnerId()); switch (message.variableHeader().connectReturnCode()) { case CONNECTION_ACCEPTED: this.connectFuture.setSuccess(new MqttConnectResult(true, MqttConnectReturnCode.CONNECTION_ACCEPTED, channel.closeFuture())); @@ -219,9 +221,9 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler } pendingSubscription.onSubackReceived(); for (MqttPendingSubscription.MqttPendingHandler handler : pendingSubscription.getHandlers()) { - MqttSubscription subscription = new MqttSubscription(pendingSubscription.getTopic(), handler.getHandler(), handler.isOnce()); + MqttSubscription subscription = new MqttSubscription(pendingSubscription.getTopic(), handler.handler(), handler.once()); this.client.getSubscriptions().put(pendingSubscription.getTopic(), subscription); - this.client.getHandlerToSubscription().put(handler.getHandler(), subscription); + this.client.getHandlerToSubscription().put(handler.handler(), subscription); } this.client.getPendingSubscribeTopics().remove(pendingSubscription.getTopic()); @@ -282,17 +284,16 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler } private void handlePuback(MqttPubAckMessage message) { - MqttPendingPublish pendingPublish = this.client.getPendingPublishes().get(message.variableHeader().messageId()); - if (pendingPublish == null) { - return; - } - pendingPublish.getFuture().setSuccess(null); - pendingPublish.onPubackReceived(); - this.client.getPendingPublishes().remove(message.variableHeader().messageId()); - pendingPublish.getPayload().release(); - if (this.client.getCallback() != null) { - this.client.getCallback().onPubAck(message); - } + log.trace("{} Handling PUBACK", client.getClientConfig().getOwnerId()); + client.getPendingPublishes().computeIfPresent(message.variableHeader().messageId(), (__, pendingPublish) -> { + pendingPublish.getFuture().setSuccess(null); + pendingPublish.onPubackReceived(); + pendingPublish.getPayload().release(); + if (client.getCallback() != null) { + client.getCallback().onPubAck(message); + } + return null; + }); } private void handlePubrec(Channel channel, MqttMessage message) { @@ -335,6 +336,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler } private void handleDisconnect(MqttMessage message) { + log.debug("{} Handling DISCONNECT", client.getClientConfig().getOwnerId()); if (this.client.getCallback() != null) { this.client.getCallback().onDisconnect(message); } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java index db0459e08a..4d845320e8 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java @@ -184,7 +184,7 @@ public interface MqttClient { * @param config The config object to use while looking for settings * @param defaultHandler The handler for incoming messages that do not match any topic subscriptions */ - static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor){ + static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor) { return new MqttClientImpl(config, defaultHandler, handlerExecutor); } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java index 41df077d71..24feb3e58e 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java @@ -47,6 +47,26 @@ public final class MqttClientConfig { private long reconnectDelay = 1L; private int maxBytesInMessage = 8092; + @Getter + @Setter + private RetransmissionConfig retransmissionConfig; + + public record RetransmissionConfig(int maxAttempts, long initialDelayMillis, double jitterFactor) { + + public RetransmissionConfig { + if (maxAttempts < 0) { + throw new IllegalArgumentException("Max retransmission attempts (maxAttempts) must be zero or greater, but was " + maxAttempts); + } + if (initialDelayMillis < 0) { + throw new IllegalArgumentException("Initial retransmission delay (initialDelayMillis) must be zero or greater, but was " + initialDelayMillis); + } + if (jitterFactor < 0) { + throw new IllegalArgumentException("Jitter factor (jitterFactor) must be zero or greater, but was " + jitterFactor); + } + } + + } + public MqttClientConfig() { this(null); } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java index 47eae565dc..ee07752db3 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java @@ -17,6 +17,7 @@ package org.thingsboard.mqtt; import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; @@ -384,8 +385,33 @@ final class MqttClientImpl implements MqttClient { MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBLISH, false, qos, retain, 0); MqttPublishVariableHeader variableHeader = new MqttPublishVariableHeader(topic, getNewMessageId().messageId()); MqttPublishMessage message = new MqttPublishMessage(fixedHeader, variableHeader, payload); - MqttPendingPublish pendingPublish = new MqttPendingPublish(variableHeader.packetId(), future, - payload.retain(), message, qos, () -> !pendingPublishes.containsKey(variableHeader.packetId())); + + final var pendingPublish = MqttPendingPublish.builder() + .messageId(variableHeader.packetId()) + .future(future) + .payload(payload.retain()) + .message(message) + .qos(qos) + .ownerId(clientConfig.getOwnerId()) + .retransmissionConfig(clientConfig.getRetransmissionConfig()) + .pendingOperation(new PendingOperation() { + @Override + public boolean isCancelled() { + return !pendingPublishes.containsKey(variableHeader.packetId()); + } + + @Override + public void onMaxRetransmissionAttemptsReached() { + pendingPublishes.computeIfPresent(variableHeader.packetId(), (__, pendingPublish) -> { + var message = "Unable to deliver publish message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)" + .formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.packetId()); + pendingPublish.getFuture().tryFailure(new MaxRetransmissionsReachedException(message)); + pendingPublish.getPayload().release(); + return null; + }); + } + }).build(); + this.pendingPublishes.put(pendingPublish.getMessageId(), pendingPublish); ChannelFuture channelFuture = this.sendAndFlushPacket(message); @@ -499,9 +525,30 @@ final class MqttClientImpl implements MqttClient { MqttSubscribePayload payload = new MqttSubscribePayload(Collections.singletonList(subscription)); MqttSubscribeMessage message = new MqttSubscribeMessage(fixedHeader, variableHeader, payload); - final MqttPendingSubscription pendingSubscription = new MqttPendingSubscription(future, topic, message, - () -> !pendingSubscriptions.containsKey(variableHeader.messageId())); - pendingSubscription.addHandler(handler, once); + final var pendingSubscription = MqttPendingSubscription.builder() + .future(future) + .topic(topic) + .handlers(Sets.newHashSet(new MqttPendingSubscription.MqttPendingHandler(handler, once))) + .subscribeMessage(message) + .ownerId(clientConfig.getOwnerId()) + .retransmissionConfig(clientConfig.getRetransmissionConfig()) + .pendingOperation(new PendingOperation() { + @Override + public boolean isCancelled() { + return !pendingSubscriptions.containsKey(variableHeader.messageId()); + } + + @Override + public void onMaxRetransmissionAttemptsReached() { + pendingSubscriptions.computeIfPresent(variableHeader.messageId(), (__, pendingSubscription) -> { + var message = "Unable to deliver subscribe message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)" + .formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.messageId()); + pendingSubscription.getFuture().tryFailure(new MaxRetransmissionsReachedException(message)); + return null; + }); + } + }).build(); + this.pendingSubscriptions.put(variableHeader.messageId(), pendingSubscription); this.pendingSubscribeTopics.add(topic); pendingSubscription.setSent(this.sendAndFlushPacket(message) != null); //If not sent, we will send it when the connection is opened @@ -518,8 +565,29 @@ final class MqttClientImpl implements MqttClient { MqttUnsubscribePayload payload = new MqttUnsubscribePayload(Collections.singletonList(topic)); MqttUnsubscribeMessage message = new MqttUnsubscribeMessage(fixedHeader, variableHeader, payload); - MqttPendingUnsubscription pendingUnsubscription = new MqttPendingUnsubscription(promise, topic, message, - () -> !pendingServerUnsubscribes.containsKey(variableHeader.messageId())); + final var pendingUnsubscription = MqttPendingUnsubscription.builder() + .future(promise) + .topic(topic) + .unsubscribeMessage(message) + .ownerId(clientConfig.getOwnerId()) + .retransmissionConfig(clientConfig.getRetransmissionConfig()) + .pendingOperation(new PendingOperation() { + @Override + public boolean isCancelled() { + return !pendingServerUnsubscribes.containsKey(variableHeader.messageId()); + } + + @Override + public void onMaxRetransmissionAttemptsReached() { + pendingServerUnsubscribes.computeIfPresent(variableHeader.messageId(), (__, pendingUnsubscription) -> { + var message = "Unable to deliver unsubscribe message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)" + .formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.messageId()); + pendingUnsubscription.getFuture().tryFailure(new MaxRetransmissionsReachedException(message)); + return null; + }); + } + }).build(); + this.pendingServerUnsubscribes.put(variableHeader.messageId(), pendingUnsubscription); pendingUnsubscription.startRetransmissionTimer(this.eventLoop.next(), this::sendAndFlushPacket); diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java index 911bc1d395..67757d2a7a 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java @@ -17,7 +17,9 @@ package org.thingsboard.mqtt; import io.netty.channel.ChannelFuture; import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import lombok.ToString; +@ToString @SuppressWarnings({"WeakerAccess", "unused"}) public final class MqttConnectResult { @@ -42,4 +44,5 @@ public final class MqttConnectResult { public ChannelFuture getCloseFuture() { return closeFuture; } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java index e8c3ef35f7..1846bdb12b 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java @@ -21,9 +21,13 @@ import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.util.concurrent.Promise; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; import java.util.function.Consumer; +@Getter(AccessLevel.PACKAGE) final class MqttPendingPublish { private final int messageId; @@ -32,80 +36,126 @@ final class MqttPendingPublish { private final MqttPublishMessage message; private final MqttQoS qos; + @Getter(AccessLevel.NONE) private final RetransmissionHandler publishRetransmissionHandler; + @Getter(AccessLevel.NONE) private final RetransmissionHandler pubrelRetransmissionHandler; + @Setter(AccessLevel.PACKAGE) private boolean sent = false; - MqttPendingPublish(int messageId, Promise future, ByteBuf payload, MqttPublishMessage message, MqttQoS qos, PendingOperation operation) { + private MqttPendingPublish( + int messageId, + Promise future, + ByteBuf payload, + MqttPublishMessage message, + MqttQoS qos, + String ownerId, + MqttClientConfig.RetransmissionConfig retransmissionConfig, + PendingOperation pendingOperation + ) { this.messageId = messageId; this.future = future; this.payload = payload; this.message = message; this.qos = qos; - this.publishRetransmissionHandler = new RetransmissionHandler<>(operation); - this.publishRetransmissionHandler.setOriginalMessage(message); - this.pubrelRetransmissionHandler = new RetransmissionHandler<>(operation); - } - - int getMessageId() { - return messageId; - } - - Promise getFuture() { - return future; - } - - ByteBuf getPayload() { - return payload; - } - - boolean isSent() { - return sent; - } - - void setSent(boolean sent) { - this.sent = sent; - } - - MqttPublishMessage getMessage() { - return message; - } - - MqttQoS getQos() { - return qos; + publishRetransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, pendingOperation, ownerId); + publishRetransmissionHandler.setOriginalMessage(message); + pubrelRetransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, pendingOperation, ownerId); } void startPublishRetransmissionTimer(EventLoop eventLoop, Consumer sendPacket) { - this.publishRetransmissionHandler.setHandle(((fixedHeader, originalMessage) -> - sendPacket.accept(new MqttPublishMessage(fixedHeader, originalMessage.variableHeader(), this.payload.retain())))); - this.publishRetransmissionHandler.start(eventLoop); + publishRetransmissionHandler.setHandler(((fixedHeader, originalMessage) -> + sendPacket.accept(new MqttPublishMessage(fixedHeader, originalMessage.variableHeader(), payload.retain())))); + publishRetransmissionHandler.start(eventLoop); } void onPubackReceived() { - this.publishRetransmissionHandler.stop(); + publishRetransmissionHandler.stop(); } void setPubrelMessage(MqttMessage pubrelMessage) { - this.pubrelRetransmissionHandler.setOriginalMessage(pubrelMessage); + pubrelRetransmissionHandler.setOriginalMessage(pubrelMessage); } void startPubrelRetransmissionTimer(EventLoop eventLoop, Consumer sendPacket) { - this.pubrelRetransmissionHandler.setHandle((fixedHeader, originalMessage) -> + pubrelRetransmissionHandler.setHandler((fixedHeader, originalMessage) -> sendPacket.accept(new MqttMessage(fixedHeader, originalMessage.variableHeader()))); - this.pubrelRetransmissionHandler.start(eventLoop); + pubrelRetransmissionHandler.start(eventLoop); } void onPubcompReceived() { - this.pubrelRetransmissionHandler.stop(); + pubrelRetransmissionHandler.stop(); } void onChannelClosed() { - this.publishRetransmissionHandler.stop(); - this.pubrelRetransmissionHandler.stop(); + publishRetransmissionHandler.stop(); + pubrelRetransmissionHandler.stop(); if (payload != null) { payload.release(); } } + + static Builder builder() { + return new Builder(); + } + + static class Builder { + + private int messageId; + private Promise future; + private ByteBuf payload; + private MqttPublishMessage message; + private MqttQoS qos; + private String ownerId; + private MqttClientConfig.RetransmissionConfig retransmissionConfig; + private PendingOperation pendingOperation; + + Builder messageId(int messageId) { + this.messageId = messageId; + return this; + } + + Builder future(Promise future) { + this.future = future; + return this; + } + + Builder payload(ByteBuf payload) { + this.payload = payload; + return this; + } + + Builder message(MqttPublishMessage message) { + this.message = message; + return this; + } + + Builder qos(MqttQoS qos) { + this.qos = qos; + return this; + } + + Builder ownerId(String ownerId) { + this.ownerId = ownerId; + return this; + } + + Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) { + this.retransmissionConfig = retransmissionConfig; + return this; + } + + Builder pendingOperation(PendingOperation pendingOperation) { + this.pendingOperation = pendingOperation; + return this; + } + + MqttPendingPublish build() { + return new MqttPendingPublish(messageId, future, payload, message, qos, ownerId, retransmissionConfig, pendingOperation); + } + + } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java index af5d53a06c..7b2ba613cb 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java @@ -18,90 +18,123 @@ package org.thingsboard.mqtt; import io.netty.channel.EventLoop; import io.netty.handler.codec.mqtt.MqttSubscribeMessage; import io.netty.util.concurrent.Promise; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; import java.util.HashSet; import java.util.Set; import java.util.function.Consumer; +import static java.util.Objects.requireNonNullElseGet; + +@Getter(AccessLevel.PACKAGE) final class MqttPendingSubscription { private final Promise future; private final String topic; - private final Set handlers = new HashSet<>(); + private final Set handlers; private final MqttSubscribeMessage subscribeMessage; + @Getter(AccessLevel.NONE) private final RetransmissionHandler retransmissionHandler; + @Setter(AccessLevel.PACKAGE) private boolean sent = false; - MqttPendingSubscription(Promise future, String topic, MqttSubscribeMessage message, PendingOperation operation) { + private MqttPendingSubscription( + Promise future, + String topic, + Set handlers, + MqttSubscribeMessage subscribeMessage, + String ownerId, + MqttClientConfig.RetransmissionConfig retransmissionConfig, + PendingOperation operation + ) { this.future = future; this.topic = topic; - this.subscribeMessage = message; + this.handlers = requireNonNullElseGet(handlers, HashSet::new); + this.subscribeMessage = subscribeMessage; - this.retransmissionHandler = new RetransmissionHandler<>(operation); - this.retransmissionHandler.setOriginalMessage(message); + retransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, operation, ownerId); + retransmissionHandler.setOriginalMessage(subscribeMessage); } - Promise getFuture() { - return future; - } + record MqttPendingHandler(MqttHandler handler, boolean once) {} - String getTopic() { - return topic; + void addHandler(MqttHandler handler, boolean once) { + handlers.add(new MqttPendingHandler(handler, once)); } - boolean isSent() { - return sent; + void startRetransmitTimer(EventLoop eventLoop, Consumer sendPacket) { + if (sent) { // If the packet is sent, we can start the retransmission timer + retransmissionHandler.setHandler((fixedHeader, originalMessage) -> + sendPacket.accept(new MqttSubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload()))); + retransmissionHandler.start(eventLoop); + } } - void setSent(boolean sent) { - this.sent = sent; + void onSubackReceived() { + retransmissionHandler.stop(); } - MqttSubscribeMessage getSubscribeMessage() { - return subscribeMessage; + void onChannelClosed() { + retransmissionHandler.stop(); } - void addHandler(MqttHandler handler, boolean once) { - this.handlers.add(new MqttPendingHandler(handler, once)); + static Builder builder() { + return new Builder(); } - Set getHandlers() { - return handlers; - } + static class Builder { - void startRetransmitTimer(EventLoop eventLoop, Consumer sendPacket) { - if (this.sent) { //If the packet is sent, we can start the retransmit timer - this.retransmissionHandler.setHandle((fixedHeader, originalMessage) -> - sendPacket.accept(new MqttSubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload()))); - this.retransmissionHandler.start(eventLoop); + private Promise future; + private String topic; + private Set handlers; + private MqttSubscribeMessage subscribeMessage; + private String ownerId; + private PendingOperation pendingOperation; + private MqttClientConfig.RetransmissionConfig retransmissionConfig; + + Builder future(Promise future) { + this.future = future; + return this; } - } - void onSubackReceived() { - this.retransmissionHandler.stop(); - } + Builder topic(String topic) { + this.topic = topic; + return this; + } - final class MqttPendingHandler { - private final MqttHandler handler; - private final boolean once; + Builder handlers(Set handlers) { + this.handlers = handlers; + return this; + } - MqttPendingHandler(MqttHandler handler, boolean once) { - this.handler = handler; - this.once = once; + Builder subscribeMessage(MqttSubscribeMessage subscribeMessage) { + this.subscribeMessage = subscribeMessage; + return this; } - MqttHandler getHandler() { - return handler; + Builder ownerId(String ownerId) { + this.ownerId = ownerId; + return this; } - boolean isOnce() { - return once; + Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) { + this.retransmissionConfig = retransmissionConfig; + return this; + } + + Builder pendingOperation(PendingOperation pendingOperation) { + this.pendingOperation = pendingOperation; + return this; + } + + MqttPendingSubscription build() { + return new MqttPendingSubscription(future, topic, handlers, subscribeMessage, ownerId, retransmissionConfig, pendingOperation); } - } - void onChannelClosed() { - this.retransmissionHandler.stop(); } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java index 9cb3bd2f8d..8bc23292f8 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java @@ -18,43 +18,96 @@ package org.thingsboard.mqtt; import io.netty.channel.EventLoop; import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage; import io.netty.util.concurrent.Promise; +import lombok.AccessLevel; +import lombok.Getter; import java.util.function.Consumer; -final class MqttPendingUnsubscription{ +@Getter(AccessLevel.PACKAGE) +final class MqttPendingUnsubscription { private final Promise future; private final String topic; + @Getter(AccessLevel.NONE) private final RetransmissionHandler retransmissionHandler; - MqttPendingUnsubscription(Promise future, String topic, MqttUnsubscribeMessage unsubscribeMessage, PendingOperation operation) { + private MqttPendingUnsubscription( + Promise future, + String topic, + MqttUnsubscribeMessage unsubscribeMessage, + String ownerId, + MqttClientConfig.RetransmissionConfig retransmissionConfig, + PendingOperation operation + ) { this.future = future; this.topic = topic; - this.retransmissionHandler = new RetransmissionHandler<>(operation); - this.retransmissionHandler.setOriginalMessage(unsubscribeMessage); + retransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, operation, ownerId); + retransmissionHandler.setOriginalMessage(unsubscribeMessage); } - Promise getFuture() { - return future; + void startRetransmissionTimer(EventLoop eventLoop, Consumer sendPacket) { + retransmissionHandler.setHandler((fixedHeader, originalMessage) -> + sendPacket.accept(new MqttUnsubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload()))); + retransmissionHandler.start(eventLoop); } - String getTopic() { - return topic; + void onUnsubackReceived() { + retransmissionHandler.stop(); } - void startRetransmissionTimer(EventLoop eventLoop, Consumer sendPacket) { - this.retransmissionHandler.setHandle((fixedHeader, originalMessage) -> - sendPacket.accept(new MqttUnsubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload()))); - this.retransmissionHandler.start(eventLoop); + void onChannelClosed() { + retransmissionHandler.stop(); } - void onUnsubackReceived(){ - this.retransmissionHandler.stop(); + static Builder builder() { + return new Builder(); } - void onChannelClosed(){ - this.retransmissionHandler.stop(); + static class Builder { + + private Promise future; + private String topic; + private MqttUnsubscribeMessage unsubscribeMessage; + private String ownerId; + private PendingOperation pendingOperation; + private MqttClientConfig.RetransmissionConfig retransmissionConfig; + + Builder future(Promise future) { + this.future = future; + return this; + } + + Builder topic(String topic) { + this.topic = topic; + return this; + } + + Builder unsubscribeMessage(MqttUnsubscribeMessage unsubscribeMessage) { + this.unsubscribeMessage = unsubscribeMessage; + return this; + } + + Builder ownerId(String ownerId) { + this.ownerId = ownerId; + return this; + } + + Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) { + this.retransmissionConfig = retransmissionConfig; + return this; + } + + Builder pendingOperation(PendingOperation pendingOperation) { + this.pendingOperation = pendingOperation; + return this; + } + + MqttPendingUnsubscription build() { + return new MqttPendingUnsubscription(future, topic, unsubscribeMessage, ownerId, retransmissionConfig, pendingOperation); + } + } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java index 70a4992d72..3fc2c6246e 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java @@ -42,12 +42,11 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter { } @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - if (!(msg instanceof MqttMessage)) { + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (!(msg instanceof MqttMessage message)) { ctx.fireChannelRead(msg); return; } - MqttMessage message = (MqttMessage) msg; if (message.fixedHeader().messageType() == MqttMessageType.PINGREQ) { this.handlePingReq(ctx.channel()); } else if (message.fixedHeader().messageType() == MqttMessageType.PINGRESP) { @@ -61,28 +60,29 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter { public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { super.userEventTriggered(ctx, evt); - if (evt instanceof IdleStateEvent) { - IdleStateEvent event = (IdleStateEvent) evt; + if (evt instanceof IdleStateEvent event) { switch (event.state()) { case READER_IDLE: log.debug("[{}] No reads were performed for specified period for channel {}", event.state(), ctx.channel().id()); - this.sendPingReq(ctx.channel()); + this.sendPingReq(ctx.channel(), event); break; case WRITER_IDLE: log.debug("[{}] No writes were performed for specified period for channel {}", event.state(), ctx.channel().id()); - this.sendPingReq(ctx.channel()); + this.sendPingReq(ctx.channel(), event); break; } } } - private void sendPingReq(Channel channel) { + private void sendPingReq(Channel channel, IdleStateEvent idleEvent) { log.trace("[{}] Sending ping request", channel.id()); MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PINGREQ, false, MqttQoS.AT_MOST_ONCE, false, 0); channel.writeAndFlush(new MqttMessage(fixedHeader)); if (this.pingRespTimeout == null) { + log.trace("[{}] Scheduling disconnect due to {}", channel.id(), idleEvent); this.pingRespTimeout = channel.eventLoop().schedule(() -> { + log.trace("[{}] Sending disconnect due to {}", channel.id(), idleEvent); MqttFixedHeader fixedHeader2 = new MqttFixedHeader(MqttMessageType.DISCONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0); channel.writeAndFlush(new MqttMessage(fixedHeader2)).addListener(ChannelFutureListener.CLOSE); //TODO: what do when the connection is closed ? @@ -99,6 +99,7 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter { private void handlePingResp(Channel channel) { log.trace("[{}] Handling ping response", channel.id()); if (this.pingRespTimeout != null && !this.pingRespTimeout.isCancelled() && !this.pingRespTimeout.isDone()) { + log.trace("[{}] Cancelling disconnect due to idle event because ping response was received", channel.id()); this.pingRespTimeout.cancel(true); this.pingRespTimeout = null; } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java index b859b216e6..07e472abb3 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java @@ -17,6 +17,8 @@ package org.thingsboard.mqtt; public interface PendingOperation { - boolean isCanceled(); + boolean isCancelled(); + + void onMaxRetransmissionAttemptsReached(); } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java index b0d9ba9002..1778abc593 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java @@ -18,66 +18,119 @@ package org.thingsboard.mqtt; import io.netty.channel.EventLoop; import io.netty.handler.codec.mqtt.MqttFixedHeader; import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.util.concurrent.ScheduledFuture; import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +@Slf4j @RequiredArgsConstructor final class RetransmissionHandler { - private volatile boolean stopped; + private final MqttClientConfig.RetransmissionConfig config; private final PendingOperation pendingOperation; + + private volatile boolean stopped; private ScheduledFuture timer; - private int timeout = 10; + private int attemptCount = 0; + + @Setter private BiConsumer handler; + + // the three fields below are used for logging only + private final String ownerId; + private String originalMessageId; + private long totalWaitingTimeMillis; + private T originalMessage; + void setOriginalMessage(T originalMessage) { + this.originalMessage = originalMessage; + var variableHeader = originalMessage.variableHeader(); + if (variableHeader instanceof MqttMessageIdVariableHeader messageIdVariableHeader) { + originalMessageId = String.valueOf(messageIdVariableHeader.messageId()); + } else if (variableHeader instanceof MqttPublishVariableHeader publishVariableHeader) { + originalMessageId = String.valueOf(publishVariableHeader.packetId()); + } else { + originalMessageId = "N/A"; + } + } + void start(EventLoop eventLoop) { if (eventLoop == null) { throw new NullPointerException("eventLoop"); } - if (this.handler == null) { + if (handler == null) { throw new NullPointerException("handler"); } - this.timeout = 10; - this.startTimer(eventLoop); + log.debug("{}MessageID[{}] Starting retransmission handler", ownerId, originalMessageId); + startTimer(eventLoop); } private void startTimer(EventLoop eventLoop) { - if (stopped || pendingOperation.isCanceled()) { + if (stopped || pendingOperation.isCancelled()) { return; } - this.timer = eventLoop.schedule(() -> { - if (stopped || pendingOperation.isCanceled()) { + + // Calculate the base delay using exponential backoff. + // For attemptCount == 0, delay = initial delay; for each subsequent attempt, the base delay doubles. + long baseDelay = config.initialDelayMillis() * (long) Math.pow(2, attemptCount); + // Apply jitter: random factor between (1 - jitterFactor) and (1 + jitterFactor). + double minFactor = 1.0 - config.jitterFactor(); + double maxFactor = 1.0 + config.jitterFactor(); + double randomFactor = config.jitterFactor() == 0 ? 1 : ThreadLocalRandom.current().nextDouble(minFactor, maxFactor); + long delayMillisWithJitter = (long) (baseDelay * randomFactor); + totalWaitingTimeMillis += delayMillisWithJitter; + + timer = eventLoop.schedule(() -> { + if (stopped || pendingOperation.isCancelled()) { return; } - this.timeout += 5; - boolean isDup = this.originalMessage.fixedHeader().isDup(); - if (this.originalMessage.fixedHeader().messageType() == MqttMessageType.PUBLISH && this.originalMessage.fixedHeader().qosLevel() != MqttQoS.AT_MOST_ONCE) { - isDup = true; + + attemptCount++; + if (attemptCount > config.maxAttempts()) { + log.debug( + "{}MessageID[{}] Gave up after {} retransmission attempts; waited a total of {} ms without receiving acknowledgement", + ownerId, originalMessageId, config.maxAttempts(), totalWaitingTimeMillis + ); + stop(); + pendingOperation.onMaxRetransmissionAttemptsReached(); + return; } - MqttFixedHeader fixedHeader = new MqttFixedHeader(this.originalMessage.fixedHeader().messageType(), isDup, this.originalMessage.fixedHeader().qosLevel(), this.originalMessage.fixedHeader().isRetain(), this.originalMessage.fixedHeader().remainingLength()); - handler.accept(fixedHeader, originalMessage); + + log.debug("{}MessageID[{}] Retransmission attempt #{} out of {}", ownerId, originalMessageId, attemptCount, config.maxAttempts()); + + var originalFixedHeader = originalMessage.fixedHeader(); + var newFixedHeader = new MqttFixedHeader( + originalFixedHeader.messageType(), + isDup(originalFixedHeader), + originalFixedHeader.qosLevel(), + originalFixedHeader.isRetain(), + originalFixedHeader.remainingLength() + ); + handler.accept(newFixedHeader, originalMessage); startTimer(eventLoop); - }, timeout, TimeUnit.SECONDS); + }, delayMillisWithJitter, TimeUnit.MILLISECONDS); + } + + private static boolean isDup(MqttFixedHeader originalFixedHeader) { + return originalFixedHeader.isDup() || (originalFixedHeader.messageType() == MqttMessageType.PUBLISH && originalFixedHeader.qosLevel() != MqttQoS.AT_MOST_ONCE); } void stop() { + log.debug("{}MessageID[{}] Stopping retransmission handler", ownerId, originalMessageId); stopped = true; - if (this.timer != null) { - this.timer.cancel(true); + if (timer != null) { + timer.cancel(true); } } - void setHandle(BiConsumer runnable) { - this.handler = runnable; - } - - void setOriginalMessage(T originalMessage) { - this.originalMessage = originalMessage; - } } diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java new file mode 100644 index 0000000000..1481b354ee --- /dev/null +++ b/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java @@ -0,0 +1,210 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.mqtt; + +import com.google.common.util.concurrent.Futures; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.util.ResourceLeakDetector; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.Promise; +import lombok.extern.slf4j.Slf4j; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionTimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.hivemq.HiveMQContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import org.thingsboard.common.util.AbstractListeningExecutor; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +@Testcontainers +class MqttClientTest { + + final int randomPort = 0; + + @Container + HiveMQContainer broker = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce").withTag("2025.2")); + + MqttTestProxy proxy; + + MqttClient client; + + AbstractListeningExecutor handlerExecutor; + + @BeforeAll + static void init() { + ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID); + } + + @BeforeEach + void setup() { + handlerExecutor = new AbstractListeningExecutor() { + @Override + protected int getThreadPollSize() { + return 1; + } + }; + handlerExecutor.init(); + } + + @AfterEach + void cleanup() { + if (client != null) { + client.disconnect(); + client = null; + } + if (proxy != null) { + proxy.stop(); + proxy = null; + } + handlerExecutor.destroy(); + handlerExecutor = null; + } + + @Test + void testConnectToBroker() { + // GIVEN + var clientConfig = new MqttClientConfig(); + clientConfig.setOwnerId("Test[ConnectToBroker]"); + clientConfig.setClientId("connect"); + + client = MqttClient.create(clientConfig, null, handlerExecutor); + + // WHEN + Promise connectFuture = client.connect(broker.getHost(), broker.getMqttPort()); + + // THEN + assertThat(connectFuture).isNotNull(); + + Awaitility.await("waiting for client to connect") + .atMost(Duration.ofSeconds(10L)) + .until(connectFuture::isDone); + + assertThat(connectFuture.isSuccess()).isTrue(); + + MqttConnectResult actualConnectResult = connectFuture.getNow(); + assertThat(actualConnectResult).isNotNull(); + assertThat(actualConnectResult.isSuccess()).isTrue(); + assertThat(actualConnectResult.getReturnCode()).isEqualTo(MqttConnectReturnCode.CONNECTION_ACCEPTED); + + assertThat(client.isConnected()).isTrue(); + } + + @Test + void testDisconnectDueToKeepAliveIfNoActivity() { + // GIVEN + proxy = MqttTestProxy.builder() + .localPort(randomPort) + .brokerHost(broker.getHost()) + .brokerPort(broker.getMqttPort()) + .brokerToClientInterceptor(msg -> msg.fixedHeader().messageType() != MqttMessageType.PINGRESP) // drop all ping responses to simulate broker down + .build(); + + int idleTimeoutSeconds = 2; + + var clientConfig = new MqttClientConfig(); + clientConfig.setOwnerId("Test[KeepAliveDisconnect]"); + clientConfig.setClientId("no-activity-disconnect"); + clientConfig.setTimeoutSeconds(idleTimeoutSeconds); + clientConfig.setReconnect(false); // disable auto reconnect + client = MqttClient.create(clientConfig, null, handlerExecutor); + + // WHEN-THEN + connect(broker.getHost(), proxy.getPort()); + + // no activity... + + Awaitility.await("waiting for client to disconnect") + .pollDelay(Duration.ofSeconds(idleTimeoutSeconds * 2)) // 2 seconds to wait for the first idle event and then 2 seconds for scheduled disconnect to fire + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertThat(client.isConnected()).isFalse()); + } + + @Test + void testRetransmission() { + // GIVEN + proxy = MqttTestProxy.builder() + .localPort(randomPort) + .brokerHost(broker.getHost()) + .brokerPort(broker.getMqttPort()) + .brokerToClientInterceptor(msg -> msg.fixedHeader().messageType() != MqttMessageType.PUBACK) // drop all pubacks to allow retransmission to happen + .build(); + + // create client + var clientConfig = new MqttClientConfig(); + clientConfig.setOwnerId("Test[Retransmission]"); + clientConfig.setClientId("retransmission"); + clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(1, 1000L, 0d)); + client = MqttClient.create(clientConfig, null, handlerExecutor); + + // connect to a broker + connect(broker.getHost(), proxy.getPort()); + + // subscribe to a topic + String topic = "test-topic"; + List receivedMessages = Collections.synchronizedList(new ArrayList<>(2)); + Future subscribeFuture = client.on(topic, (__, payload) -> { + receivedMessages.add(payload); + return Futures.immediateVoidFuture(); + }); + Awaitility.await("waiting for client to subscribe to a topic") + .atMost(Duration.ofSeconds(10L)) + .until(subscribeFuture::isDone); + + // WHEN + // publish a message + ByteBuf message = PooledByteBufAllocator.DEFAULT.buffer().writeBytes("test message".getBytes(StandardCharsets.UTF_8)); + client.publish(topic, message, MqttQoS.AT_LEAST_ONCE); + + // THEN + // wait enough time so that retransmission happens and stops + // if retransmission works incorrectly waiting 10 seconds allows for additional retransmissions to happen + try { + Awaitility.await("wait up to 10s, stop early if too many messages") + .atMost(Duration.ofSeconds(10L)) + .pollInterval(Duration.ofMillis(100)) + .until(() -> receivedMessages.size() > 2); + } catch (ConditionTimeoutException __) { + // didn't exceed 2 messages + } + + assertThat(receivedMessages).size().describedAs("incorrect number of messages received, expected 2 (original plus one retransmitted)").isEqualTo(2); + } + + private void connect(String host, int port) { + Promise connectFuture = client.connect(host, port); + Awaitility.await("waiting for client to connect") + .atMost(Duration.ofSeconds(10L)) + .until(connectFuture::isSuccess); + } + +} diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java deleted file mode 100644 index 83e3b1c8d5..0000000000 --- a/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.mqtt; - -import io.netty.channel.Channel; -import io.netty.channel.ChannelFuture; -import io.netty.channel.ChannelFutureListener; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.DefaultEventLoop; -import io.netty.handler.timeout.IdleStateEvent; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.concurrent.TimeUnit; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.after; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class MqttPingHandlerTest { - - static final int KEEP_ALIVE_SECONDS = 0; - static final int PROCESS_SEND_DISCONNECT_MSG_TIME_MS = 500; - - MqttPingHandler mqttPingHandler; - - @BeforeEach - void setUp() { - mqttPingHandler = new MqttPingHandler(KEEP_ALIVE_SECONDS); - } - - @Test - void givenChannelReaderIdleState_whenNoPingResponse_thenDisconnectClient() throws Exception { - ChannelHandlerContext ctx = mock(ChannelHandlerContext.class); - Channel channel = mock(Channel.class); - when(ctx.channel()).thenReturn(channel); - when(channel.eventLoop()).thenReturn(new DefaultEventLoop()); - ChannelFuture channelFuture = mock(ChannelFuture.class); - when(channel.writeAndFlush(any())).thenReturn(channelFuture); - - mqttPingHandler.userEventTriggered(ctx, IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT); - verify( - channelFuture, - after(TimeUnit.SECONDS.toMillis(KEEP_ALIVE_SECONDS) + PROCESS_SEND_DISCONNECT_MSG_TIME_MS) - ).addListener(eq(ChannelFutureListener.CLOSE)); - } -} \ No newline at end of file diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java new file mode 100644 index 0000000000..4a10fc3bfb --- /dev/null +++ b/netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java @@ -0,0 +1,202 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.mqtt; + +import io.netty.bootstrap.Bootstrap; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.mqtt.MqttDecoder; +import io.netty.handler.codec.mqtt.MqttEncoder; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.util.ReferenceCountUtil; +import lombok.extern.slf4j.Slf4j; + +import java.net.InetSocketAddress; +import java.util.function.Predicate; + +@Slf4j +public class MqttTestProxy { + + private final EventLoopGroup bossGroup; + private final EventLoopGroup workerGroup; + + private Channel clientToProxyChannel; + private Channel proxyToBrokerChannel; + + private final int assignedPort; + + private boolean stopped; + + private final Predicate brokerToClientInterceptor; + + private MqttTestProxy(Builder builder) { + log.info("Starting MQTT proxy..."); + + brokerToClientInterceptor = builder.brokerToClientInterceptor != null ? builder.brokerToClientInterceptor : msg -> true; + bossGroup = new NioEventLoopGroup(1); + workerGroup = new NioEventLoopGroup(1); + + ServerBootstrap proxyBootstrap = new ServerBootstrap(); + proxyBootstrap.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel channel) { + clientToProxyChannel = channel; + clientToProxyChannel.config().setAutoRead(false); // do not accept data before we connected to a broker + + connectToBroker(builder.brokerHost, builder.brokerPort).addListener(future -> { + if (future.isSuccess()) { + clientToProxyChannel.pipeline().addLast("mqttDecoder", new MqttDecoder()); + clientToProxyChannel.pipeline().addLast("mqttToBroker", new MqttRelayHandler(proxyToBrokerChannel, null)); + clientToProxyChannel.pipeline().addLast("mqttEncoder", MqttEncoder.INSTANCE); + + clientToProxyChannel.config().setAutoRead(true); // start accepting data for a client + } else { + log.error("Failed to connect to broker", future.cause()); + clientToProxyChannel.close(); + } + }); + } + }); + + try { + Channel proxyChannel = proxyBootstrap.bind(builder.localPort).sync().channel(); + assignedPort = ((InetSocketAddress) proxyChannel.localAddress()).getPort(); + } catch (Exception e) { + log.error("Failed to start MQTT proxy", e); + throw new RuntimeException("Failed to start MQTT proxy", e); + } + + log.info("MQTT proxy started on port {}", assignedPort); + } + + private ChannelFuture connectToBroker(String brokerHost, int brokerPort) { + Bootstrap proxyToBrokerBootstrap = new Bootstrap(); + proxyToBrokerBootstrap.group(workerGroup) + .channel(NioSocketChannel.class) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel channel) { + proxyToBrokerChannel = channel; + proxyToBrokerChannel.pipeline().addLast(new MqttDecoder()); + proxyToBrokerChannel.pipeline().addLast("mqttToClient", new MqttRelayHandler(clientToProxyChannel, brokerToClientInterceptor)); + proxyToBrokerChannel.pipeline().addLast(MqttEncoder.INSTANCE); + } + }); + return proxyToBrokerBootstrap.connect(brokerHost, brokerPort); + } + + private static class MqttRelayHandler extends SimpleChannelInboundHandler { + + private final Channel targetChannel; + private final Predicate interceptor; + + private MqttRelayHandler(Channel targetChannel, Predicate interceptor) { + this.targetChannel = targetChannel; + this.interceptor = interceptor; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) { + log.debug("Received message: {}", msg.fixedHeader().messageType()); + if (interceptor == null || interceptor.test(msg)) { + if (targetChannel.isActive()) { + targetChannel.writeAndFlush(ReferenceCountUtil.retain(msg)); + } + } else { + log.info("Dropping message: {}", msg.fixedHeader().messageType()); + } + } + + } + + public void stop() { + if (stopped) { + log.info("MQTT proxy was already stopped"); + return; + } + + stopped = true; + + log.info("Stopping MQTT proxy..."); + + if (clientToProxyChannel != null) { + clientToProxyChannel.close(); + } + if (proxyToBrokerChannel != null) { + proxyToBrokerChannel.close(); + } + if (bossGroup != null) { + bossGroup.shutdownGracefully(); + } + if (workerGroup != null) { + workerGroup.shutdownGracefully(); + } + + log.info("MQTT proxy stopped"); + } + + public int getPort() { + return assignedPort; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private int localPort; + private String brokerHost; + private int brokerPort; + private Predicate brokerToClientInterceptor; + + public Builder localPort(int localPort) { + this.localPort = localPort; + return this; + } + + public Builder brokerHost(String brokerHost) { + this.brokerHost = brokerHost; + return this; + } + + public Builder brokerPort(int brokerPort) { + this.brokerPort = brokerPort; + return this; + } + + public Builder brokerToClientInterceptor(Predicate interceptor) { + this.brokerToClientInterceptor = interceptor; + return this; + } + + public MqttTestProxy build() { + return new MqttTestProxy(this); + } + + } +} diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java deleted file mode 100644 index db177c84b6..0000000000 --- a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.mqtt.integration; - -import io.netty.buffer.Unpooled; -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.handler.codec.mqtt.MqttMessageType; -import io.netty.handler.codec.mqtt.MqttQoS; -import io.netty.util.concurrent.Future; -import io.netty.util.concurrent.Promise; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.parallel.ResourceLock; -import org.thingsboard.common.util.AbstractListeningExecutor; -import org.thingsboard.mqtt.MqttClient; -import org.thingsboard.mqtt.MqttClientConfig; -import org.thingsboard.mqtt.MqttConnectResult; -import org.thingsboard.mqtt.integration.server.MqttServer; - -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -@ResourceLock("port8885") // test MQTT server port -@Slf4j -public class MqttIntegrationTest { - - static final String MQTT_HOST = "localhost"; - static final int KEEPALIVE_TIMEOUT_SECONDS = 2; - static final long RECONNECT_DELAY_SECONDS = 10L; - - EventLoopGroup eventLoopGroup; - MqttServer mqttServer; - - MqttClient mqttClient; - - AbstractListeningExecutor handlerExecutor; - - @BeforeEach - public void init() throws Exception { - this.handlerExecutor = new AbstractListeningExecutor() { - @Override - protected int getThreadPollSize() { - return 4; - } - }; - handlerExecutor.init(); - - this.eventLoopGroup = new NioEventLoopGroup(); - - this.mqttServer = new MqttServer(); - this.mqttServer.init(); - } - - @AfterEach - public void destroy() throws InterruptedException { - if (this.mqttClient != null) { - this.mqttClient.disconnect(); - } - if (this.mqttServer != null) { - this.mqttServer.shutdown(); - } - if (this.eventLoopGroup != null) { - this.eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS); - } - if (this.handlerExecutor != null) { - this.handlerExecutor.destroy(); - } - } - - @Test - public void givenActiveMqttClient_whenNoActivityForKeepAliveTimeout_thenDisconnectClient() throws Throwable { - //given - this.mqttClient = initClient(); - - log.warn("Sending publish messages..."); - CountDownLatch latch = new CountDownLatch(3); - for (int i = 0; i < 3; i++) { - Thread.sleep(30); - Future pubFuture = publishMsg(); - pubFuture.addListener(future -> latch.countDown()); - } - - log.warn("Waiting for messages acknowledgments..."); - boolean awaitResult = latch.await(10, TimeUnit.SECONDS); - Assertions.assertTrue(awaitResult); - log.warn("Messages are delivered successfully..."); - - //when - log.warn("Starting idle period..."); - Thread.sleep(5000); - - //then - List allReceivedEvents = this.mqttServer.getEventsFromClient(); - long disconnectCount = allReceivedEvents.stream().filter(type -> type == MqttMessageType.DISCONNECT).count(); - - Assertions.assertEquals(1, disconnectCount); - } - - private Future publishMsg() { - return this.mqttClient.publish( - "test/topic", - Unpooled.wrappedBuffer("payload".getBytes(StandardCharsets.UTF_8)), - MqttQoS.AT_MOST_ONCE); - } - - private MqttClient initClient() throws Exception { - MqttClientConfig config = new MqttClientConfig(); - config.setOwnerId("MqttIntegrationTest"); - config.setTimeoutSeconds(KEEPALIVE_TIMEOUT_SECONDS); - config.setReconnectDelay(RECONNECT_DELAY_SECONDS); - MqttClient client = MqttClient.create(config, null, handlerExecutor); - client.setEventLoop(this.eventLoopGroup); - Promise connectFuture = client.connect(MQTT_HOST, this.mqttServer.getMqttPort()); - - String hostPort = MQTT_HOST + ":" + this.mqttServer.getMqttPort(); - MqttConnectResult result; - try { - result = connectFuture.get(10, TimeUnit.SECONDS); - } catch (TimeoutException ex) { - connectFuture.cancel(true); - client.disconnect(); - throw new RuntimeException(String.format("Failed to connect to MQTT server at %s.", hostPort)); - } - if (!result.isSuccess()) { - connectFuture.cancel(true); - client.disconnect(); - throw new RuntimeException(String.format("Failed to connect to MQTT server at %s. Result code is: %s", hostPort, result.getReturnCode())); - } - return client; - } -} \ No newline at end of file diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java deleted file mode 100644 index ca4fb677dc..0000000000 --- a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.mqtt.integration.server; - -import io.netty.bootstrap.ServerBootstrap; -import io.netty.channel.Channel; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOption; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.SocketChannel; -import io.netty.channel.socket.nio.NioServerSocketChannel; -import io.netty.handler.codec.mqtt.MqttDecoder; -import io.netty.handler.codec.mqtt.MqttEncoder; -import io.netty.handler.codec.mqtt.MqttMessageType; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; - -@Slf4j -public class MqttServer { - - @Getter - private final List eventsFromClient = new CopyOnWriteArrayList<>(); - @Getter - private final int mqttPort = 8885; - - private Channel serverChannel; - private EventLoopGroup bossGroup; - private EventLoopGroup workerGroup; - - public void init() throws Exception { - log.info("Starting MQTT server on port {}...", mqttPort); - bossGroup = new NioEventLoopGroup(); - workerGroup = new NioEventLoopGroup(); - ServerBootstrap b = new ServerBootstrap(); - b.group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel ch) throws Exception { - ChannelPipeline pipeline = ch.pipeline(); - pipeline.addLast("decoder", new MqttDecoder(65536)); - pipeline.addLast("encoder", MqttEncoder.INSTANCE); - - MqttTransportHandler handler = new MqttTransportHandler(eventsFromClient); - - pipeline.addLast(handler); - ch.closeFuture().addListener(handler); - } - }) - .childOption(ChannelOption.SO_KEEPALIVE, true); - - serverChannel = b.bind(mqttPort).sync().channel(); - log.info("Mqtt transport started!"); - } - - public void shutdown() throws InterruptedException { - log.info("Stopping MQTT transport!"); - try { - serverChannel.close().sync(); - } finally { - workerGroup.shutdownGracefully(); - bossGroup.shutdownGracefully(); - } - log.info("MQTT transport stopped!"); - } -} diff --git a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java b/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java deleted file mode 100644 index 5c433d7069..0000000000 --- a/netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.mqtt.integration.server; - -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInboundHandlerAdapter; -import io.netty.handler.codec.mqtt.MqttConnAckMessage; -import io.netty.handler.codec.mqtt.MqttConnAckVariableHeader; -import io.netty.handler.codec.mqtt.MqttConnectMessage; -import io.netty.handler.codec.mqtt.MqttConnectReturnCode; -import io.netty.handler.codec.mqtt.MqttFixedHeader; -import io.netty.handler.codec.mqtt.MqttMessage; -import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; -import io.netty.handler.codec.mqtt.MqttMessageType; -import io.netty.handler.codec.mqtt.MqttPubAckMessage; -import io.netty.handler.codec.mqtt.MqttPublishMessage; -import io.netty.util.ReferenceCountUtil; -import io.netty.util.concurrent.Future; -import io.netty.util.concurrent.GenericFutureListener; -import lombok.extern.slf4j.Slf4j; - -import java.util.List; -import java.util.UUID; - -import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK; -import static io.netty.handler.codec.mqtt.MqttMessageType.CONNECT; -import static io.netty.handler.codec.mqtt.MqttMessageType.DISCONNECT; -import static io.netty.handler.codec.mqtt.MqttMessageType.PINGREQ; -import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK; -import static io.netty.handler.codec.mqtt.MqttMessageType.PUBLISH; -import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE; - -@Slf4j -public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener> { - - private final List eventsFromClient; - private final UUID sessionId; - - MqttTransportHandler(List eventsFromClient) { - this.sessionId = UUID.randomUUID(); - this.eventsFromClient = eventsFromClient; - } - - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) { - log.trace("[{}] Processing msg: {}", sessionId, msg); - try { - if (msg instanceof MqttMessage) { - MqttMessage message = (MqttMessage) msg; - if (message.decoderResult().isSuccess()) { - processMqttMsg(ctx, message); - } else { - log.error("[{}] Message decoding failed: {}", sessionId, message.decoderResult().cause().getMessage()); - ctx.close(); - } - } else { - log.debug("[{}] Received non mqtt message: {}", sessionId, msg.getClass().getSimpleName()); - ctx.close(); - } - } finally { - ReferenceCountUtil.safeRelease(msg); - } - } - - void processMqttMsg(ChannelHandlerContext ctx, MqttMessage msg) { - if (msg.fixedHeader() == null) { - ctx.close(); - return; - } - switch (msg.fixedHeader().messageType()) { - case CONNECT: - eventsFromClient.add(CONNECT); - processConnect(ctx, (MqttConnectMessage) msg); - break; - case DISCONNECT: - eventsFromClient.add(DISCONNECT); - ctx.close(); - break; - case PUBLISH: - // QoS 0 and 1 supported only here - eventsFromClient.add(PUBLISH); - MqttPublishMessage mqttPubMsg = (MqttPublishMessage) msg; - ack(ctx, mqttPubMsg.variableHeader().packetId()); - break; - case PINGREQ: - // We will not handle PINGREQ and will not send any PINGRESP to simulate the MQTT server is down - eventsFromClient.add(PINGREQ); - break; - default: - break; - } - } - - void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) { - String userName = msg.payload().userName(); - String clientId = msg.payload().clientIdentifier(); - - log.warn("[{}][{}] Processing connect msg for client: {}!", sessionId, userName, clientId); - ctx.writeAndFlush(createMqttConnAckMsg(msg)); - } - - private MqttConnAckMessage createMqttConnAckMsg(MqttConnectMessage msg) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(CONNACK, false, AT_MOST_ONCE, false, 0); - MqttConnAckVariableHeader mqttConnAckVariableHeader = - new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_ACCEPTED, !msg.variableHeader().isCleanSession()); - return new MqttConnAckMessage(mqttFixedHeader, mqttConnAckVariableHeader); - } - - private void ack(ChannelHandlerContext ctx, int msgId) { - if (msgId > 0) { - ctx.writeAndFlush(createMqttPubAckMsg(msgId)); - } - } - - public static MqttPubAckMessage createMqttPubAckMsg(int requestId) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(PUBACK, false, AT_MOST_ONCE, false, 0); - MqttMessageIdVariableHeader mqttMsgIdVariableHeader = - MqttMessageIdVariableHeader.from(requestId); - return new MqttPubAckMessage(mqttFixedHeader, mqttMsgIdVariableHeader); - } - - @Override - public void operationComplete(Future future) { - log.trace("[{}] Channel closed!", sessionId); - } -} diff --git a/netty-mqtt/src/test/resources/junit-platform.properties b/netty-mqtt/src/test/resources/junit-platform.properties deleted file mode 100644 index f2ed301920..0000000000 --- a/netty-mqtt/src/test/resources/junit-platform.properties +++ /dev/null @@ -1,3 +0,0 @@ -junit.jupiter.execution.parallel.enabled = true -junit.jupiter.execution.parallel.mode.default = concurrent -junit.jupiter.execution.parallel.mode.classes.default = concurrent diff --git a/pom.xml b/pom.xml index 72195b1e4f..70e0777462 100755 --- a/pom.xml +++ b/pom.xml @@ -1957,6 +1957,12 @@ ${testcontainers.version} test + + org.testcontainers + hivemq + ${testcontainers.version} + test + org.springframework.data spring-data-redis diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java new file mode 100644 index 0000000000..4ac05b57d0 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.api; + +public interface MqttClientSettings { + + int getRetransmissionMaxAttempts(); + + long getRetransmissionInitialDelayMillis(); + + double getRetransmissionJitterFactor(); + +} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index b66c9e13d5..7989b8f9ce 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -416,4 +416,9 @@ public interface TbContext { EventService getEventService(); AuditLogService getAuditLogService(); + + // Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node + + MqttClientSettings getMqttClientSettings(); + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java index 4d99951e1a..28a9e1ff4b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java @@ -26,6 +26,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; import org.thingsboard.mqtt.MqttConnectResult; +import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -126,6 +127,13 @@ public class TbMqttNode extends TbAbstractExternalNode { } config.setCleanSession(this.mqttNodeConfiguration.isCleanSession()); + MqttClientSettings mqttClientSettings = ctx.getMqttClientSettings(); + config.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig( + mqttClientSettings.getRetransmissionMaxAttempts(), + mqttClientSettings.getRetransmissionInitialDelayMillis(), + mqttClientSettings.getRetransmissionJitterFactor() + )); + prepareMqttClientConfig(config); MqttClient client = getMqttClient(ctx, config); client.setEventLoop(ctx.getSharedEventLoop()); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java index f6ccfbca6f..bf650af8bb 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java @@ -40,6 +40,7 @@ import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; import org.thingsboard.mqtt.MqttConnectResult; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -80,6 +81,7 @@ import static org.mockito.BDDMockito.spy; import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.willAnswer; import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest { @@ -106,6 +108,22 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest { protected void setUp() { mqttNode = spy(new TbMqttNode()); mqttNodeConfig = new TbMqttNodeConfiguration().defaultConfiguration(); + lenient().when(ctxMock.getMqttClientSettings()).thenReturn(new MqttClientSettings() { + @Override + public int getRetransmissionMaxAttempts() { + return 3; + } + + @Override + public long getRetransmissionInitialDelayMillis() { + return 5000L; + } + + @Override + public double getRetransmissionJitterFactor() { + return 0.15; + } + }); } @Test From 777228afe4d235b4b835403cbd2c80a0e4e2dbc2 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Thu, 1 May 2025 16:39:46 +0300 Subject: [PATCH 13/38] UI: Rename id property --- .../system/scada_symbols/bottom-right-elbow-connector-hp.svg | 4 ++-- .../json/system/scada_symbols/bottom-tee-connector-hp.svg | 4 ++-- .../data/json/system/scada_symbols/cross-connector-hp.svg | 2 +- .../json/system/scada_symbols/horizontal-connector-hp.svg | 4 ++-- .../system/scada_symbols/left-bottom-elbow-connector-hp.svg | 4 ++-- .../data/json/system/scada_symbols/left-tee-connector-hp.svg | 4 ++-- .../json/system/scada_symbols/left-top-elbow-connector-hp.svg | 4 ++-- .../system/scada_symbols/long-horizontal-connector-hp.svg | 4 ++-- .../json/system/scada_symbols/long-vertical-connector-hp.svg | 4 ++-- .../data/json/system/scada_symbols/right-tee-connector-hp.svg | 4 ++-- .../system/scada_symbols/top-right-elbow-connector-hp.svg | 4 ++-- .../data/json/system/scada_symbols/top-tee-connector-hp.svg | 4 ++-- .../data/json/system/scada_symbols/vertical-connector-hp.svg | 4 ++-- 13 files changed, 25 insertions(+), 25 deletions(-) diff --git a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg index f7ffd5f167..a0a56e37e5 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null } ], @@ -131,7 +131,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg index bdbbdca81f..90cfe6ed2b 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null }, { @@ -368,7 +368,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg index db6b091e0e..5ac0af8248 100644 --- a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null }, { diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg index 93ef81f57b..f2fc9fb2e0 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg @@ -12,7 +12,7 @@ }, { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null } ], @@ -167,7 +167,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg index 1c24cca2e0..8fd4ee09df 100644 --- a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null } ], @@ -131,7 +131,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg index 461e91fe94..c3b4e67d56 100644 --- a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null }, { @@ -368,7 +368,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg index 19e217a428..961c760f63 100644 --- a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null } ], @@ -131,7 +131,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg index 0fe805c9a7..f5f68c4934 100644 --- a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg @@ -12,7 +12,7 @@ }, { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null } ], @@ -167,7 +167,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg index 411859b5ed..f344823082 100644 --- a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg @@ -12,7 +12,7 @@ }, { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null } ], @@ -167,7 +167,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg index dafcf53647..346d134f30 100644 --- a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null }, { @@ -368,7 +368,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg index f9f2fe20d2..ca255abf14 100644 --- a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null } ], @@ -131,7 +131,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg index f61bf85ccd..e63d465d8e 100644 --- a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg @@ -7,7 +7,7 @@ "tags": [ { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null }, { @@ -368,7 +368,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, diff --git a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg index 9667928e4e..87c3ef23ee 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg @@ -12,7 +12,7 @@ }, { "tag": "line", - "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.lineSize});", + "stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});", "actions": null } ], @@ -167,7 +167,7 @@ ], "properties": [ { - "id": "lineSize", + "id": "mainLineSize", "name": "{i18n:scada.symbol.line}", "type": "number", "default": 6, From 357a1512e863150366fa0f234f9814daf8a57db1 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 2 May 2025 11:26:06 +0300 Subject: [PATCH 14/38] UI: Improved dashboard autocomplete - ignoreError and add error handling --- .../components/dashboard-autocomplete.component.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts index 08893efa91..4a74faaf66 100644 --- a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts @@ -190,15 +190,22 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI this.searchText = ''; if (value != null) { if (typeof value === 'string') { - this.dashboardService.getDashboardInfo(value).subscribe( - (dashboard) => { + this.dashboardService.getDashboardInfo(value, {ignoreLoading: true, ignoreErrors: true}).subscribe({ + next: (dashboard) => { this.modelValue = this.useIdValue ? dashboard.id.id : dashboard; if (this.useDashboardLink) { this.dashboardURL = getEntityDetailsPageURL(this.modelValue as string, EntityType.DASHBOARD); } this.selectDashboardFormGroup.get('dashboard').patchValue(dashboard, {emitEvent: false}); + }, + error: () => { + this.modelValue = null; + this.selectDashboardFormGroup.get('dashboard').patchValue('', {emitEvent: false}); + if (this.required) { + this.propagateChange(this.modelValue); + } } - ); + }); } else { this.modelValue = this.useIdValue ? value.id.id : value; this.selectDashboardFormGroup.get('dashboard').patchValue(value, {emitEvent: false}); From b7081eed0314ca8cee655744096c4f27b99151b7 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 2 May 2025 11:27:28 +0300 Subject: [PATCH 15/38] UI: Add completion for new scada api --- .../widget/lib/scada/scada-symbol.models.ts | 2 +- .../scada-symbol-editor.models.ts | 64 +++++++++++++++++++ ui-ngx/src/app/shared/models/constants.ts | 1 + 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts index 9e0d07fed4..7bd835de88 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts @@ -84,7 +84,7 @@ export interface ScadaSymbolApi { resetCssAnimation: (element: Element) => void; finishCssAnimation: (element: Element) => void; connectorAnimation:(element: Element) => ConnectorScadaSymbolAnimation | undefined; - connectorAnimate:(element: Element) => ConnectorScadaSymbolAnimation; + connectorAnimate:(element: Element, path: string, reversedPath: string) => ConnectorScadaSymbolAnimation; resetConnectorAnimation: (element: Element) => void; finishConnectorAnimation: (element: Element) => void; disable: (element: Element | Element[]) => void; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts index 13b55d4f67..56d2801670 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts @@ -1129,6 +1129,8 @@ export const scadaSymbolContextCompletion = (metadata: ScadaSymbolMetadata, tags const scadaSymbolAnimationLink = HelpLinks.linksMap.scadaSymbolDevAnimation; const scadaSymbolAnimation = `ScadaSymbolAnimation`; + const connectorScadaSymbolAnimationLink = HelpLinks.linksMap.scadaSymbolDevConnectorAnimation; + const connectorScadaSymbolAnimation = `ConnectorScadaSymbolAnimation`; const properties: TbEditorCompletion = { meta: 'object', @@ -1229,6 +1231,68 @@ export const scadaSymbolContextCompletion = (metadata: ScadaSymbolMetadata, tags }, ] }, + connectorAnimate: { + meta: 'function', + description: 'Finishes any previous connector animation and starts a new connector animation for the SVG element along the specified path.', + args: [ + { + name: 'element', + description: 'SVG element', + type: 'Element' + }, + { + name: 'path', + description: 'Path defining the animation trajectory', + type: 'string' + }, + { + name: 'reversedPath', + description: 'Path for the reversed animation trajectory', + type: 'string' + } + ], + return: { + description: `Instance of ${connectorScadaSymbolAnimation} class with API to setup and control the connector animation.`, + type: connectorScadaSymbolAnimation + } + }, + connectorAnimation: { + meta: 'function', + description: 'Gets the current connector animation applied to the SVG element.', + args: [ + { + name: 'element', + description: 'SVG element', + type: 'Element' + } + ], + return: { + description: `Instance of ${connectorScadaSymbolAnimation} class with API to setup and control the connector animation, or undefined if no animation is applied.`, + type: 'ConnectorScadaSymbolAnimation | undefined' + } + }, + resetConnectorAnimation: { + meta: 'function', + description: 'Stops the connector animation if any and restores the SVG element to its initial state, removes the connector animation instance.', + args: [ + { + name: 'element', + description: 'SVG element', + type: 'Element' + } + ] + }, + finishConnectorAnimation: { + meta: 'function', + description: 'Finishes the connector animation if any, updates the SVG element state according to the end animation values, removes the connector animation instance.', + args: [ + { + name: 'element', + description: 'SVG element', + type: 'Element' + } + ] + }, generateElementId: { meta: 'function', description: 'Generates new unique element id.', diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index e1306e577c..0f22de90b9 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -194,6 +194,7 @@ export const HelpLinks = { scada: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/scada`, scadaSymbolDev: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/scada/scada-symbols-dev-guide/`, scadaSymbolDevAnimation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/scada/scada-symbols-dev-guide/#scadasymbolanimation`, + scadaSymbolDevConnectorAnimation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/scada/scada-symbols-dev-guide/#connectorscadasymbolanimation`, domains: `${helpBaseUrl}/docs${docPlatformPrefix}/domains`, mobileApplication: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/applications/`, mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`, From 71724b95d6a0e903c24df7148f02bddde423bd76 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 2 May 2025 13:14:51 +0300 Subject: [PATCH 16/38] UI: Fixed percent value for doughnut chart --- .../home/components/widget/lib/chart/pie-chart.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts index 2cb5c5a031..c985fcb55a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts @@ -122,17 +122,10 @@ export class TbPieChart extends TbLatestChart { seriesData.push( {id: dataItem.id, value: dataItem.value, name: dataItem.dataKey.label, itemStyle: {color: dataItem.dataKey.color}} ); - if (this.settings.doughnut && enabledDataItems.length > 1) { - seriesData.push({ - value: 0, name: '', itemStyle: {color: 'transparent'}, emphasis: {disabled: true} - }); - } } } if (this.settings.doughnut) { - for (let i = 1; i < seriesData.length; i += 2) { - seriesData[i].value = this.total / 100; - } + this.latestChartOption.series[0].padAngle = 2; } this.latestChartOption.series[0].data = seriesData; } From 1c5fc16b58eb37d82d08b3fc55f853e3cfc47f86 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 2 May 2025 13:19:40 +0300 Subject: [PATCH 17/38] UI: Remove unused constant --- .../app/modules/home/components/widget/lib/chart/pie-chart.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts index c985fcb55a..ec56ce5478 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts @@ -116,7 +116,6 @@ export class TbPieChart extends TbLatestChart { protected doUpdateSeriesData() { const seriesData: PieDataItemOption[] = []; - const enabledDataItems = this.dataItems.filter(item => item.enabled && item.hasValue); for (const dataItem of this.dataItems) { if (dataItem.enabled && dataItem.hasValue) { seriesData.push( From 23ded89dea5498c626b84cc0d4637a14e11c71ed Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 2 May 2025 13:31:37 +0300 Subject: [PATCH 18/38] UI: Refactoring --- .../app/modules/home/components/widget/lib/chart/pie-chart.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts index ec56ce5478..d4279caa69 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts @@ -116,6 +116,7 @@ export class TbPieChart extends TbLatestChart { protected doUpdateSeriesData() { const seriesData: PieDataItemOption[] = []; + const enabledDataItems = this.dataItems.filter(item => item.enabled && item.hasValue); for (const dataItem of this.dataItems) { if (dataItem.enabled && dataItem.hasValue) { seriesData.push( @@ -124,7 +125,7 @@ export class TbPieChart extends TbLatestChart { } } if (this.settings.doughnut) { - this.latestChartOption.series[0].padAngle = 2; + this.latestChartOption.series[0].padAngle = enabledDataItems.length > 1 ? 2 : 0; } this.latestChartOption.series[0].data = seriesData; } From a0a77c721f5081e8bbfab4eed6321942290a7f60 Mon Sep 17 00:00:00 2001 From: Yevhen Bondarenko <56396344+YevhenBondarenko@users.noreply.github.com> Date: Tue, 6 May 2025 13:31:34 +0200 Subject: [PATCH 19/38] fixed 'value too long' after saving ota (#13277) * fixed 'value too long' after saving ota * fixed tests --- .../server/service/entitiy/ota/DefaultTbOtaPackageService.java | 2 +- .../thingsboard/server/controller/OtaPackageControllerTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java index fca015671e..af8bbeb669 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java @@ -86,7 +86,7 @@ public class DefaultTbOtaPackageService extends AbstractTbEntityService implemen otaPackage.setContentType(contentType); otaPackage.setData(ByteBuffer.wrap(data)); otaPackage.setDataSize((long) data.length); - OtaPackageInfo savedOtaPackage = otaPackageService.saveOtaPackage(otaPackage); + OtaPackageInfo savedOtaPackage = new OtaPackageInfo(otaPackageService.saveOtaPackage(otaPackage)); logEntityActionService.logEntityAction(tenantId, savedOtaPackage.getId(), savedOtaPackage, null, actionType, user); return savedOtaPackage; } catch (Exception e) { diff --git a/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java index e8bb65dd49..3fc839996c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java @@ -216,7 +216,7 @@ public class OtaPackageControllerTest extends AbstractControllerTest { Assert.assertEquals(CHECKSUM_ALGORITHM, savedFirmware.getChecksumAlgorithm().name()); Assert.assertEquals(CHECKSUM, savedFirmware.getChecksum()); - testNotifyEntityAllOneTime(savedFirmware, savedFirmware.getId(), savedFirmware.getId(), + testNotifyEntityAllOneTime(new OtaPackageInfo(savedFirmware), savedFirmware.getId(), savedFirmware.getId(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UPDATED); } From dd76f2d8231cc754e9254bce68b63d13e17a2674 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 7 May 2025 11:44:54 +0300 Subject: [PATCH 20/38] UI: Fixed rule node config style --- .../gps-geo-action-config.component.html | 2 +- .../math-function-config.component.html | 2 +- ...save-to-custom-table-config.component.html | 2 +- .../arguments-map-config.component.html | 2 +- ...vice-relations-query-config.component.html | 2 +- .../message-types-config.component.html | 2 +- .../common/select-attributes.component.html | 2 +- .../entity-details-config.component.html | 2 +- .../external/lambda-config.component.html | 4 +- .../check-message-config.component.html | 4 +- .../originator-type-config.component.html | 2 +- .../copy-keys-config.component.html | 2 +- .../deduplication-config.component.html | 6 +-- .../delete-keys-config.component.html | 2 +- .../rulechain/rule-node-config.component.html | 2 +- .../rulechain/rule-node-config.component.scss | 53 ++++++++++++++++++- .../rulechain/rule-node-config.component.ts | 10 ++-- 17 files changed, 77 insertions(+), 24 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html index 37e93157d3..2d3f230643 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html @@ -116,7 +116,7 @@ rule-node-config.polygon-definition - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html index 3147162f06..4d4940404e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html @@ -77,7 +77,7 @@ rule-node-config.key-field-input - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html index c1d6dd3215..c9685f2c3f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html @@ -19,7 +19,7 @@ rule-node-config.custom-table-name - rule-node-config.argument-key-field-input - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html index ce99d6f7e6..0da5cae858 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html @@ -60,7 +60,7 @@ [emptyInputPlaceholder]="'rule-node-config.add-device-profile' | translate" [filledInputPlaceholder]="'rule-node-config.add-device-profile' | translate" formControlName="deviceTypes"> - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html index ab436088bd..dcc968dee3 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html @@ -62,7 +62,7 @@ - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html index 88c4cc1bdb..49669c147f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html @@ -53,7 +53,7 @@ - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html index 11f0323346..5d80cfb276 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html @@ -22,7 +22,7 @@ [placeholder]="'rule-node-config.add-detail' | translate" [requiredText]="'rule-node-config.entity-details-list-empty' | translate" formControlName="detailsList"> - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html index 67f0aaca7f..7de1a46a9d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html @@ -85,7 +85,7 @@ {{ 'rule-node-config.connection-timeout-min' | translate }} - help @@ -98,7 +98,7 @@ {{ 'rule-node-config.request-timeout-min' | translate }} - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html index 7dd89d4927..0bdba52c1e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html @@ -26,14 +26,14 @@ [label]="'rule-node-config.data-keys' | translate" [placeholder]="'rule-node-config.add-message-field' | translate" formControlName="messageNames"> - help - help
- help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html index b1e810bcb2..3d524b8402 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html @@ -27,7 +27,7 @@ [placeholder]="'rule-node-config.add-key' | translate" [requiredText]="'rule-node-config.key-val.at-least-one-key-error' | translate" formControlName="keys"> - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html index 3df9c1d7d3..aa6ff0bab7 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html @@ -25,7 +25,7 @@ {{'rule-node-config.interval-min-error' | translate}} - help @@ -73,7 +73,7 @@ {{'rule-node-config.max-pending-msgs-min-error' | translate}} - help @@ -89,7 +89,7 @@ {{'rule-node-config.max-retries-min-error' | translate}} - help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html index 75f9c243e9..4385341686 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html @@ -26,7 +26,7 @@ [placeholder]="'rule-node-config.add-key' | translate" [requiredText]="'rule-node-config.key-val.at-least-one-key-error' | translate" formControlName="keys"> - help diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html index 3522c584f4..0228b8d973 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
{{definedDirectiveError}}
* { + flex: 1; + } + + .flex-2 { + flex: 2; + } + + .third-width { + max-width: 32%; + @media #{$mat-xs} { + max-width: 100%; + } + } + } } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts index e3e5d939df..d27e4d8abb 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts @@ -19,11 +19,13 @@ import { ComponentRef, EventEmitter, forwardRef, + HostBinding, Input, OnDestroy, Output, ViewChild, - ViewContainerRef + ViewContainerRef, + ViewEncapsulation } from '@angular/core'; import { ControlValueAccessor, @@ -52,14 +54,16 @@ import { RuleChainType } from '@shared/models/rule-chain.models'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RuleNodeConfigComponent), multi: true - }] + }], + encapsulation: ViewEncapsulation.None }) export class RuleNodeConfigComponent implements ControlValueAccessor, OnDestroy { @ViewChild('definedConfigContent', {read: ViewContainerRef, static: true}) definedConfigContainer: ViewContainerRef; - @ViewChild('jsonObjectEditComponent') jsonObjectEditComponent: JsonObjectEditComponent; + @HostBinding('style.display') readonly styleDisplay = 'block'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; From 3ea08293fa4982ce24660a07d8878af4fd777c1d Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Wed, 7 May 2025 16:32:25 +0300 Subject: [PATCH 21/38] removed resource CF --- .../org/thingsboard/server/controller/BaseController.java | 8 ++++++-- .../server/controller/CalculatedFieldController.java | 6 ++---- .../server/service/security/permission/Resource.java | 3 +-- .../security/permission/TenantAdminPermissions.java | 1 - 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 73e278389a..d29d398dca 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -964,8 +964,12 @@ public abstract class BaseController { } } - protected CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException { - return checkEntityId(calculatedFieldId, calculatedFieldService::findById, operation); + private void checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException { + validateId(calculatedFieldId, "Invalid entity id"); + SecurityUser user = getCurrentUser(); + CalculatedField cf = calculatedFieldService.findById(user.getTenantId(), calculatedFieldId); + checkNotNull(cf, calculatedFieldId.getEntityType().getNormalName() + " with id [" + calculatedFieldId + "] is not found"); + checkEntityId(cf.getEntityId(), operation); } protected HomeDashboardInfo getHomeDashboardInfo(SecurityUser securityUser, JsonNode additionalInfo) { diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index f899d0f480..1c00988f6a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -59,7 +59,6 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngi import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; -import org.thingsboard.server.service.security.permission.Resource; import java.util.ArrayList; import java.util.Collections; @@ -136,7 +135,6 @@ public class CalculatedFieldController extends BaseController { public CalculatedField saveCalculatedField(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the calculated field.") @RequestBody CalculatedField calculatedField) throws Exception { calculatedField.setTenantId(getTenantId()); - checkEntity(calculatedField.getId(), calculatedField, Resource.CALCULATED_FIELD); checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD); checkReferencedEntities(calculatedField.getConfiguration(), getCurrentUser()); return tbCalculatedFieldService.save(calculatedField, getCurrentUser()); @@ -186,7 +184,7 @@ public class CalculatedFieldController extends BaseController { public void deleteCalculatedField(@PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws Exception { checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); - CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.DELETE); + CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser()); checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD); tbCalculatedFieldService.delete(calculatedField, getCurrentUser()); } @@ -200,7 +198,7 @@ public class CalculatedFieldController extends BaseController { public JsonNode getLatestCalculatedFieldDebugEvent(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException { checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); - CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.READ); + CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser()); checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD); TenantId tenantId = getCurrentUser().getTenantId(); return Optional.ofNullable(eventService.findLatestEvents(tenantId, calculatedFieldId, EventType.DEBUG_CALCULATED_FIELD, 1)) diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 9d7590f786..4cb281a719 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -50,8 +50,7 @@ public enum Resource { VERSION_CONTROL, NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), - MOBILE_APP_SETTINGS, - CALCULATED_FIELD(EntityType.CALCULATED_FIELD); + MOBILE_APP_SETTINGS; private final Set entityTypes; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index a072cf2738..7a67d6739e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -55,7 +55,6 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.OAUTH2_CONFIGURATION_TEMPLATE, new PermissionChecker.GenericPermissionChecker(Operation.READ)); put(Resource.MOBILE_APP, tenantEntityPermissionChecker); put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); - put(Resource.CALCULATED_FIELD, tenantEntityPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { From 67048b936dc1a79c89941866d613e8775e2ce2a0 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 9 May 2025 10:08:58 +0300 Subject: [PATCH 22/38] UI: Remove sticky option for argument table header row --- .../calculated-field-arguments-table.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html index 53d8a9d7b6..b4b5a939e1 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html @@ -116,7 +116,7 @@ + *matHeaderRowDef="['name', 'entityType', 'target', 'type', 'key', 'actions']"> From ecc82d8c048ed7f0d699ff54dc1d53c0ac623d5d Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 9 May 2025 12:23:51 +0300 Subject: [PATCH 23/38] UI: Event table hide clear event button as input --- .../app/modules/home/components/event/event-table-config.ts | 4 ++-- .../modules/home/components/event/event-table.component.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts index 0335c43f29..03fc641735 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts @@ -59,7 +59,6 @@ import { AppState } from '@core/core.state'; export class EventTableConfig extends EntityTableConfig { eventTypeValue: EventType | DebugEventType; - hideClearEventAction = false; private filterParams: FilterEventBody = {}; private filterColumns: FilterEntityColumn[] = []; @@ -95,7 +94,8 @@ export class EventTableConfig extends EntityTableConfig { private cd: ChangeDetectorRef, private store: Store, public testButtonLabel?: string, - private debugEventSelected?: EventEmitter) { + private debugEventSelected?: EventEmitter, + public hideClearEventAction = false) { super(); this.loadDataOnInit = false; this.tableTitle = ''; diff --git a/ui-ngx/src/app/modules/home/components/event/event-table.component.ts b/ui-ngx/src/app/modules/home/components/event/event-table.component.ts index d12791680b..f51cf66d8a 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table.component.ts @@ -58,6 +58,9 @@ export class EventTableComponent implements OnInit, AfterViewInit, OnDestroy { @Input() debugEventTypes: Array; + @Input() + hideClearEventAction: boolean = false; + activeValue = false; dirtyValue = false; entityIdValue: EntityId; @@ -147,7 +150,8 @@ export class EventTableComponent implements OnInit, AfterViewInit, OnDestroy { this.cd, this.store, this.functionTestButtonLabel, - this.debugEventSelected + this.debugEventSelected, + this.hideClearEventAction ); } From 590805b6a3ec92e868e6a9b6427cc32f4670a998 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Fri, 9 May 2025 13:25:31 +0300 Subject: [PATCH 24/38] Handle uncaught auth exceptions --- .../ThingsboardSecurityConfiguration.java | 7 ++- .../server/controller/BaseController.java | 9 +--- .../ThingsboardErrorResponseHandler.java | 26 ++++++++++- .../security/auth/AuthExceptionHandler.java | 46 +++++++++++++++++++ .../data/exception/ThingsboardErrorCode.java | 3 +- 5 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index 871798ccd1..93cf9da8e0 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -47,6 +47,7 @@ import org.springframework.web.filter.ShallowEtagHeaderFilter; import org.thingsboard.server.dao.oauth2.OAuth2Configuration; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.AuthExceptionHandler; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; import org.thingsboard.server.service.security.auth.jwt.JwtTokenAuthenticationProcessingFilter; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider; @@ -129,6 +130,9 @@ public class ThingsboardSecurityConfiguration { @Autowired private RateLimitProcessingFilter rateLimitProcessingFilter; + @Autowired + private AuthExceptionHandler authExceptionHandler; + @Bean protected PayloadSizeFilter payloadSizeFilter() { return new PayloadSizeFilter(maxPayloadSizeConfig); @@ -235,7 +239,8 @@ public class ThingsboardSecurityConfiguration { .addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(payloadSizeFilter(), UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(authExceptionHandler, buildRestLoginProcessingFilter().getClass()); if (oauth2Configuration != null) { http.oauth2Login(login -> login .authorizationEndpoint(config -> config diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index d29d398dca..93bf5b35fe 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -437,14 +437,7 @@ public abstract class BaseController { } else if (exception instanceof AsyncRequestTimeoutException) { return new ThingsboardException("Request timeout", ThingsboardErrorCode.GENERAL); } else if (exception instanceof DataAccessException) { - if (!logControllerErrorStackTrace) { // not to log the error twice - log.warn("Database error: {} - {}", exception.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(exception)); - } - if (cause instanceof ConstraintViolationException) { - return new ThingsboardException(ExceptionUtils.getRootCause(exception).getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); - } else { - return new ThingsboardException("Database error", ThingsboardErrorCode.GENERAL); - } + return new ThingsboardException(exception, ThingsboardErrorCode.DATABASE); } else if (exception instanceof EntityVersionMismatchException) { return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.VERSION_CONFLICT); } diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java index 9a08441936..7be11e5748 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -21,7 +21,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -139,6 +141,8 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand ThingsboardException thingsboardException = (ThingsboardException) exception; if (thingsboardException.getErrorCode() == ThingsboardErrorCode.SUBSCRIPTION_VIOLATION) { handleSubscriptionException((ThingsboardException) exception, response); + } else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.DATABASE) { + handleDatabaseException(thingsboardException.getCause(), response); } else { handleThingsboardException((ThingsboardException) exception, response); } @@ -148,8 +152,10 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand handleAccessDeniedException(response); } else if (exception instanceof AuthenticationException) { handleAuthenticationException((AuthenticationException) exception, response); - } else if (exception instanceof MaxPayloadSizeExceededException) { + } else if (exception instanceof MaxPayloadSizeExceededException) { handleMaxPayloadSizeExceededException(response, (MaxPayloadSizeExceededException) exception); + } else if (exception instanceof DataAccessException e) { + handleDatabaseException(e, response); } else { response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(exception.getMessage(), @@ -201,6 +207,17 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand JacksonUtil.fromBytes(((HttpClientErrorException) subscriptionException.getCause()).getResponseBodyAsByteArray(), Object.class)); } + private void handleDatabaseException(Throwable databaseException, HttpServletResponse response) throws IOException { + ThingsboardErrorResponse errorResponse; + if (databaseException instanceof ConstraintViolationException) { + errorResponse = ThingsboardErrorResponse.of(ExceptionUtils.getRootCause(databaseException).getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST); + } else { + log.warn("Database error: {} - {}", databaseException.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(databaseException)); + errorResponse = ThingsboardErrorResponse.of("Database error", ThingsboardErrorCode.DATABASE, HttpStatus.INTERNAL_SERVER_ERROR); + } + writeResponse(errorResponse, response); + } + private void handleAccessDeniedException(HttpServletResponse response) throws IOException { response.setStatus(HttpStatus.FORBIDDEN.value()); JacksonUtil.writeValue(response.getWriter(), @@ -233,4 +250,11 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand } } + // TODO: refactor this class to use this method instead of boilerplate JacksonUtil.writeValue(response.getWriter(), ... + private void writeResponse(ThingsboardErrorResponse errorResponse, HttpServletResponse response) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(errorResponse.getStatus()); + JacksonUtil.writeValue(response.getWriter(), errorResponse); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java new file mode 100644 index 0000000000..27ed2996d1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.security.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AuthExceptionHandler extends OncePerRequestFilter { + + private final ThingsboardErrorResponseHandler errorResponseHandler; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { + try { + filterChain.doFilter(request, response); + } catch (AuthenticationException e) { + throw e; + } catch (Exception e) { + errorResponseHandler.handle(e, response); + } + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java index 13c65fe6e6..deae86a9de 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java @@ -31,7 +31,8 @@ public enum ThingsboardErrorCode { TOO_MANY_UPDATES(34), VERSION_CONFLICT(35), SUBSCRIPTION_VIOLATION(40), - PASSWORD_VIOLATION(45); + PASSWORD_VIOLATION(45), + DATABASE(46); private int errorCode; From ed5868d8e5d825d033d638a4da297ca1a397f285 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 9 May 2025 14:16:53 +0300 Subject: [PATCH 25/38] removed permission check for tenant --- .../server/controller/CalculatedFieldController.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index 1c00988f6a..0dcf22e433 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -270,7 +270,10 @@ public class CalculatedFieldController extends BaseController { for (EntityId referencedEntityId : referencedEntityIds) { EntityType entityType = referencedEntityId.getEntityType(); switch (entityType) { - case TENANT, CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); + case TENANT -> { + return; + } + case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); } From 9bb50ae355265dae626432855e87f4788fb3ddb8 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 9 May 2025 14:55:28 +0300 Subject: [PATCH 26/38] UI: Refacoring hide clear event --- .../app/modules/home/components/event/event-table.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/event/event-table.component.ts b/ui-ngx/src/app/modules/home/components/event/event-table.component.ts index f51cf66d8a..53cb8e1b96 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table.component.ts @@ -156,7 +156,7 @@ export class EventTableComponent implements OnInit, AfterViewInit, OnDestroy { } ngAfterViewInit() { - this.isEmptyData$ = this.entitiesTable.dataSource.isEmpty().subscribe(value => this.eventTableConfig.hideClearEventAction = value); + this.isEmptyData$ = this.entitiesTable.dataSource.isEmpty().subscribe(value => this.eventTableConfig.hideClearEventAction = value || this.hideClearEventAction); } ngOnDestroy() { From 8b00da12c4ae5434d0b6c0033caf3b6ffb53e6b8 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Sat, 10 May 2025 12:16:25 +0200 Subject: [PATCH 27/38] tools cassandra-all bumped to v5 --- pom.xml | 7 +------ .../thingsboard/client/tools/migrator/WriterBuilder.java | 6 +++--- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index f95e9df9b1..e5d6c0e165 100755 --- a/pom.xml +++ b/pom.xml @@ -57,7 +57,7 @@ 0.10 4.17.0 4.2.25 - 3.11.17 + 5.0.4 33.1.0-jre 3.1.8 3.14.0 @@ -1827,11 +1827,6 @@ cassandra-all ${cassandra-all.version} - - org.apache.cassandra - cassandra-thrift - ${cassandra-all.version} - org.junit.vintage junit-vintage-engine diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java index b7f8524031..80fdad3c1c 100644 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java +++ b/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java @@ -59,7 +59,7 @@ public class WriterBuilder { public static CQLSSTableWriter getTsWriter(File dir) { return CQLSSTableWriter.builder() - .inDirectory(dir) + .inDirectory(dir.getAbsolutePath()) .forTable(tsSchema) .using("INSERT INTO thingsboard.ts_kv_cf (entity_type, entity_id, key, partition, ts, bool_v, str_v, long_v, dbl_v, json_v) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") @@ -68,7 +68,7 @@ public class WriterBuilder { public static CQLSSTableWriter getLatestWriter(File dir) { return CQLSSTableWriter.builder() - .inDirectory(dir) + .inDirectory(dir.getAbsolutePath()) .forTable(latestSchema) .using("INSERT INTO thingsboard.ts_kv_latest_cf (entity_type, entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") @@ -77,7 +77,7 @@ public class WriterBuilder { public static CQLSSTableWriter getPartitionWriter(File dir) { return CQLSSTableWriter.builder() - .inDirectory(dir) + .inDirectory(dir.getAbsolutePath()) .forTable(partitionSchema) .using("INSERT INTO thingsboard.ts_kv_partitions_cf (entity_type, entity_id, key, partition) " + "VALUES (?, ?, ?, ?)") From 5111a07a1154e92263e11138bab8078acd54a8c9 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Sat, 10 May 2025 12:23:05 +0200 Subject: [PATCH 28/38] addressing vulnerabilities on high and critical --- pom.xml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index e5d6c0e165..cd2078e70a 100755 --- a/pom.xml +++ b/pom.xml @@ -42,13 +42,14 @@ 4.0.2 2.4.0-b180830.0359 4.0.5 - 10.1.39 + 10.1.40 + 2.5.2 3.2.12 3.2.12 3.2.12 6.1.15 6.2.11 - 6.2.8 + 6.3.8 5.1.5 0.12.5 2.0.13 @@ -102,7 +103,7 @@ 1.19.0 1.78.1 2.0.1 - 42.7.3 + 42.7.5 org/thingsboard/server/gen/**/*, org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* @@ -112,7 +113,7 @@ - 3.7.1 + 3.7.2 8.10.1 3.5.3 2.2 @@ -1163,6 +1164,13 @@ tomcat-embed-websocket ${tomcat.version} + + + net.minidev + json-smart + ${net.minidev.json-smart} + + org.springframework.boot spring-boot-starter @@ -1183,6 +1191,18 @@ spring-security-oauth2-jose ${spring-security.version} + + + org.springframework.security + spring-security-config + ${spring-security.version} + + + org.springframework.security + spring-security-web + ${spring-security.version} + + org.springframework spring-core From 9d72f5f4346d621ab4d8752b331e0990cb14eee2 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Sat, 10 May 2025 12:24:24 +0200 Subject: [PATCH 29/38] bump californium version from 2.6.1 to 2.7.4 to fix vulnerability and not break the monitoring module --- monitoring/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 4f59cbc975..4ef6c775bf 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -42,7 +42,7 @@ ThingsBoard Monitoring Service org.thingsboard.monitoring.ThingsboardMonitoringApplication - 2.6.1 + 2.7.4 2.0.0-M4 From dec61b8ac28be647563f33ad2565bde0feef5039 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Sat, 10 May 2025 12:25:53 +0200 Subject: [PATCH 30/38] cassandra thrift removed after cassandra-all bump for module tools --- tools/pom.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tools/pom.xml b/tools/pom.xml index 0286c135e4..7e2c44ddd6 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -55,10 +55,6 @@ org.apache.cassandra cassandra-all - - org.apache.cassandra - cassandra-thrift - commons-io commons-io From a3c7084d7fc98492386be0217a5d7ff4c3204493 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Sat, 10 May 2025 14:24:04 +0200 Subject: [PATCH 31/38] refactored obsolete com.github.java-json-tools:json-schema-validator with actively updated com.networknt:json-schema-validator --- common/cluster-api/pom.xml | 4 - common/dao-api/pom.xml | 4 - dao/pom.xml | 4 + .../BaseComponentDescriptorService.java | 20 ++-- .../BaseComponentDescriptorServiceTest.java | 98 +++++++++++++++++++ pom.xml | 10 +- 6 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index ee9c1eae75..75048197d3 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -60,10 +60,6 @@ jakarta.annotation jakarta.annotation-api - - com.github.java-json-tools - json-schema-validator - org.slf4j slf4j-api diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index d7142e723d..d3027356c3 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -56,10 +56,6 @@ jakarta.annotation jakarta.annotation-api - - com.github.java-json-tools - json-schema-validator - org.slf4j slf4j-api diff --git a/dao/pom.xml b/dao/pom.xml index f203343c89..9618c38386 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -59,6 +59,10 @@ org.thingsboard.common util + + com.networknt + json-schema-validator + org.slf4j slf4j-api diff --git a/dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java b/dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java index 5acf1b0f97..2c5d6fb3c5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java @@ -16,10 +16,10 @@ package org.thingsboard.server.dao.component; import com.fasterxml.jackson.databind.JsonNode; -import com.github.fge.jsonschema.core.exceptions.ProcessingException; -import com.github.fge.jsonschema.core.report.ProcessingReport; -import com.github.fge.jsonschema.main.JsonSchemaFactory; -import com.github.fge.jsonschema.main.JsonValidator; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -36,6 +36,7 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.Validator; import java.util.Optional; +import java.util.Set; /** * @author Andrew Shvayka @@ -89,15 +90,18 @@ public class BaseComponentDescriptorService implements ComponentDescriptorServic @Override public boolean validate(TenantId tenantId, ComponentDescriptor component, JsonNode configuration) { - JsonValidator validator = JsonSchemaFactory.byDefault().getValidator(); try { if (!component.getConfigurationDescriptor().has("schema")) { throw new DataValidationException("Configuration descriptor doesn't contain schema property!"); } JsonNode configurationSchema = component.getConfigurationDescriptor().get("schema"); - ProcessingReport report = validator.validate(configurationSchema, configuration); - return report.isSuccess(); - } catch (ProcessingException e) { + + JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4); + JsonSchema schema = factory.getSchema(configurationSchema); + + Set validationMessages = schema.validate(configuration); + return validationMessages.isEmpty(); + } catch (Exception e) { throw new IncorrectParameterException(e.getMessage(), e); } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java new file mode 100644 index 0000000000..d76c11f708 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java @@ -0,0 +1,98 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.component; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentClusteringMode; +import org.thingsboard.server.common.data.plugin.ComponentDescriptor; +import org.thingsboard.server.common.data.plugin.ComponentScope; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.dao.exception.IncorrectParameterException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BaseComponentDescriptorServiceTest { + + private BaseComponentDescriptorService service; + private ComponentDescriptor componentDescriptor; + private TenantId tenantId; + + @BeforeEach + void setUp() { + service = Mockito.spy(BaseComponentDescriptorService.class); + tenantId = TenantId.SYS_TENANT_ID; + + // Create a simple component descriptor + componentDescriptor = new ComponentDescriptor(); + componentDescriptor.setType(ComponentType.ACTION); + componentDescriptor.setScope(ComponentScope.TENANT); + componentDescriptor.setClusteringMode(ComponentClusteringMode.ENABLED); + componentDescriptor.setName("Test Component"); + componentDescriptor.setClazz("org.thingsboard.test.TestComponent"); + + // Create configuration descriptor with schema from JSON string + String configDescriptorJson = """ + { + "schema": { + "type": "object", + "properties": { + "testField": { + "type": "string" + } + }, + "required": ["testField"] + } + }"""; + + componentDescriptor.setConfigurationDescriptor(JacksonUtil.toJsonNode(configDescriptorJson)); + } + + @Test + void testValidate() { + // Create valid configuration from JSON string + String validConfigJson = "{\"testField\": \"test value\"}"; + JsonNode validConfig = JacksonUtil.toJsonNode(validConfigJson); + + // Create invalid configuration (missing required field) from JSON string + String invalidConfigJson = "{}"; + JsonNode invalidConfig = JacksonUtil.toJsonNode(invalidConfigJson); + + // Test valid configuration + boolean validResult = service.validate(tenantId, componentDescriptor, validConfig); + assertTrue(validResult, "Valid configuration should pass validation"); + + // Test invalid configuration + boolean invalidResult = service.validate(tenantId, componentDescriptor, invalidConfig); + assertFalse(invalidResult, "Invalid configuration should fail validation"); + + // Test with component descriptor without schema + ComponentDescriptor noSchemaDescriptor = new ComponentDescriptor(componentDescriptor); + noSchemaDescriptor.setConfigurationDescriptor(JacksonUtil.toJsonNode("{}")); + + // Should throw exception when schema is missing + assertThrows(IncorrectParameterException.class, () -> { + service.validate(tenantId, noSchemaDescriptor, validConfig); + }, "Should throw exception when schema is missing"); + } + +} diff --git a/pom.xml b/pom.xml index cd2078e70a..0260dc09be 100755 --- a/pom.xml +++ b/pom.xml @@ -75,7 +75,7 @@ 2.17.2 1.7.0 4.4.0 - 2.2.14 + 1.5.6 0.6.12 3.12.1 2.0.0-M15 @@ -1620,15 +1620,9 @@ ${auth0-jwt.version} - com.github.java-json-tools + com.networknt json-schema-validator ${json-schema-validator.version} - - - com.sun.mail - mailapi - - org.eclipse.leshan From d608b87f945337908b4c731a10116eab35b080b0 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Sat, 10 May 2025 15:25:59 +0200 Subject: [PATCH 32/38] test scope mockserver with no dependency to not affect transitive dependencies --- pom.xml | 4 ++-- rule-engine/rule-engine-components/pom.xml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 0260dc09be..608a46584a 100755 --- a/pom.xml +++ b/pom.xml @@ -2233,7 +2233,7 @@ org.mock-server - mockserver-netty + mockserver-netty-no-dependencies ${mock-server.version} test @@ -2245,7 +2245,7 @@ org.mock-server - mockserver-client-java + mockserver-client-java-no-dependencies ${mock-server.version} test diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 008c422dbf..b975f7c624 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -141,12 +141,12 @@ org.mock-server - mockserver-netty + mockserver-netty-no-dependencies test org.mock-server - mockserver-client-java + mockserver-client-java-no-dependencies test From 99d2d1e03311cbba6eadf6e2959d122ec4258e67 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Sat, 10 May 2025 15:30:08 +0200 Subject: [PATCH 33/38] Monitoring COAP Leshan Dependency Upgrade --- monitoring/pom.xml | 2 - .../monitoring/client/Lwm2mClient.java | 133 +++++++++--------- 2 files changed, 69 insertions(+), 66 deletions(-) diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 4f59cbc975..e17b87454e 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -42,8 +42,6 @@ ThingsBoard Monitoring Service org.thingsboard.monitoring.ThingsboardMonitoringApplication - 2.6.1 - 2.0.0-M4 diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java index f658bd1362..599fe29525 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java @@ -20,13 +20,16 @@ import lombok.Setter; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.eclipse.californium.core.network.CoapEndpoint; -import org.eclipse.californium.core.network.config.NetworkConfig; -import org.eclipse.californium.core.observe.ObservationStore; -import org.eclipse.californium.scandium.DTLSConnector; -import org.eclipse.californium.scandium.config.DtlsConnectorConfig; -import org.eclipse.leshan.client.californium.LeshanClient; -import org.eclipse.leshan.client.californium.LeshanClientBuilder; +import org.eclipse.californium.core.config.CoapConfig; +import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.scandium.config.DtlsConfig; +import org.eclipse.leshan.client.LeshanClient; +import org.eclipse.leshan.client.LeshanClientBuilder; +import org.eclipse.leshan.client.californium.endpoint.CaliforniumClientEndpointsProvider; +import org.eclipse.leshan.client.californium.endpoint.ClientProtocolProvider; +import org.eclipse.leshan.client.californium.endpoint.coap.CoapOscoreProtocolProvider; +import org.eclipse.leshan.client.californium.endpoint.coaps.CoapsClientProtocolProvider; +import org.eclipse.leshan.client.endpoint.LwM2mClientEndpointsProvider; import org.eclipse.leshan.client.engine.DefaultRegistrationEngineFactory; import org.eclipse.leshan.client.object.Security; import org.eclipse.leshan.client.object.Server; @@ -34,9 +37,8 @@ import org.eclipse.leshan.client.observer.LwM2mClientObserver; import org.eclipse.leshan.client.resource.BaseInstanceEnabler; import org.eclipse.leshan.client.resource.DummyInstanceEnabler; import org.eclipse.leshan.client.resource.ObjectsInitializer; -import org.eclipse.leshan.client.servers.ServerIdentity; +import org.eclipse.leshan.client.servers.LwM2mServer; import org.eclipse.leshan.core.ResponseCode; -import org.eclipse.leshan.core.californium.EndpointFactory; import org.eclipse.leshan.core.model.InvalidDDFFileException; import org.eclipse.leshan.core.model.LwM2mModel; import org.eclipse.leshan.core.model.ObjectLoader; @@ -53,7 +55,6 @@ import org.thingsboard.monitoring.util.ResourceUtils; import javax.security.auth.Destroyable; import java.io.IOException; -import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -95,9 +96,11 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable { } Security security = noSec(serverUri, 123); - NetworkConfig coapConfig = new NetworkConfig().setString(NetworkConfig.Keys.COAP_PORT, StringUtils.substringAfterLast(serverUri, ":")); - - LeshanClient leshanClient; + Configuration coapConfig = new Configuration(); + String portStr = StringUtils.substringAfterLast(serverUri, ":"); + if (StringUtils.isNotEmpty(portStr)) { + coapConfig.set(CoapConfig.COAP_PORT, Integer.parseInt(portStr)); + } LwM2mModel model = new StaticModel(models); ObjectsInitializer initializer = new ObjectsInitializer(model); @@ -105,118 +108,121 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable { initializer.setInstancesForObject(SERVER, new Server(123, TimeUnit.MINUTES.toSeconds(5))); initializer.setInstancesForObject(DEVICE, this); initializer.setClassForObject(ACCESS_CONTROL, DummyInstanceEnabler.class); - DtlsConnectorConfig.Builder dtlsConfig = new DtlsConnectorConfig.Builder(); - dtlsConfig.setRecommendedCipherSuitesOnly(true); - dtlsConfig.setClientOnly(); - DefaultRegistrationEngineFactory engineFactory = new DefaultRegistrationEngineFactory(); - engineFactory.setReconnectOnUpdate(false); - engineFactory.setResumeOnConnect(true); + // Create client endpoints Provider + List protocolProvider = new ArrayList<>(); + protocolProvider.add(new CoapOscoreProtocolProvider()); + protocolProvider.add(new CoapsClientProtocolProvider()); + CaliforniumClientEndpointsProvider.Builder endpointsBuilder = new CaliforniumClientEndpointsProvider.Builder( + protocolProvider.toArray(new ClientProtocolProvider[protocolProvider.size()])); - EndpointFactory endpointFactory = new EndpointFactory() { + // Create Californium Configuration + Configuration clientCoapConfig = endpointsBuilder.createDefaultConfiguration(); - @Override - public CoapEndpoint createUnsecuredEndpoint(InetSocketAddress address, NetworkConfig coapConfig, - ObservationStore store) { - CoapEndpoint.Builder builder = new CoapEndpoint.Builder(); - builder.setInetSocketAddress(address); - builder.setNetworkConfig(coapConfig); - return builder.build(); - } + // Set some DTLS stuff + clientCoapConfig.setTransient(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY); + clientCoapConfig.set(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY, true); - @Override - public CoapEndpoint createSecuredEndpoint(DtlsConnectorConfig dtlsConfig, NetworkConfig coapConfig, - ObservationStore store) { - CoapEndpoint.Builder builder = new CoapEndpoint.Builder(); - DtlsConnectorConfig.Builder dtlsConfigBuilder = new DtlsConnectorConfig.Builder(dtlsConfig); - builder.setConnector(new DTLSConnector(dtlsConfigBuilder.build())); - builder.setNetworkConfig(coapConfig); - return builder.build(); - } - }; + // Set Californium Configuration + endpointsBuilder.setConfiguration(clientCoapConfig); + + // creates EndpointsProvider + List endpointsProvider = new ArrayList<>(); + endpointsProvider.add(endpointsBuilder.build()); + // Configure registration engine + DefaultRegistrationEngineFactory engineFactory = new DefaultRegistrationEngineFactory(); + engineFactory.setReconnectOnUpdate(false); + engineFactory.setResumeOnConnect(true); + + // Build the client LeshanClientBuilder builder = new LeshanClientBuilder(endpoint); builder.setObjects(initializer.createAll()); - builder.setCoapConfig(coapConfig); - builder.setDtlsConfig(dtlsConfig); + builder.setEndpointsProviders(endpointsProvider.toArray(new LwM2mClientEndpointsProvider[endpointsProvider.size()])); builder.setRegistrationEngineFactory(engineFactory); - builder.setEndpointFactory(endpointFactory); builder.setDecoder(new DefaultLwM2mDecoder(false)); builder.setEncoder(new DefaultLwM2mEncoder(false)); leshanClient = builder.build(); + // Add observer LwM2mClientObserver observer = new LwM2mClientObserver() { - @Override - public void onBootstrapStarted(ServerIdentity bsserver, BootstrapRequest request) {} + public void onBootstrapStarted(LwM2mServer bsserver, BootstrapRequest request) { + // No implementation needed + } @Override - public void onBootstrapSuccess(ServerIdentity bsserver, BootstrapRequest request) {} + public void onBootstrapSuccess(LwM2mServer bsserver, BootstrapRequest request) { + // No implementation needed + } @Override - public void onBootstrapFailure(ServerIdentity bsserver, BootstrapRequest request, - ResponseCode responseCode, String errorMessage, Exception cause) {} + public void onBootstrapFailure(LwM2mServer bsserver, BootstrapRequest request, ResponseCode responseCode, String errorMessage, Exception cause) { + // No implementation needed + } @Override - public void onBootstrapTimeout(ServerIdentity bsserver, BootstrapRequest request) {} + public void onBootstrapTimeout(LwM2mServer bsserver, BootstrapRequest request) { + // No implementation needed + } @Override - public void onRegistrationStarted(ServerIdentity server, RegisterRequest request) { + public void onRegistrationStarted(LwM2mServer server, RegisterRequest request) { log.debug("onRegistrationStarted [{}]", request.getEndpointName()); } @Override - public void onRegistrationSuccess(ServerIdentity server, RegisterRequest request, String registrationID) { + public void onRegistrationSuccess(LwM2mServer server, RegisterRequest request, String registrationID) { log.debug("onRegistrationSuccess [{}] [{}]", request.getEndpointName(), registrationID); } @Override - public void onRegistrationFailure(ServerIdentity server, RegisterRequest request, ResponseCode responseCode, String errorMessage, Exception cause) { + public void onRegistrationFailure(LwM2mServer server, RegisterRequest request, ResponseCode responseCode, String errorMessage, Exception cause) { log.debug("onRegistrationFailure [{}] [{}] [{}]", request.getEndpointName(), responseCode, errorMessage); } @Override - public void onRegistrationTimeout(ServerIdentity server, RegisterRequest request) { + public void onRegistrationTimeout(LwM2mServer server, RegisterRequest request) { log.debug("onRegistrationTimeout [{}]", request.getEndpointName()); } @Override - public void onUpdateStarted(ServerIdentity server, UpdateRequest request) { + public void onUpdateStarted(LwM2mServer server, UpdateRequest request) { log.debug("onUpdateStarted [{}]", request.getRegistrationId()); } @Override - public void onUpdateSuccess(ServerIdentity server, UpdateRequest request) { + public void onUpdateSuccess(LwM2mServer server, UpdateRequest request) { log.debug("onUpdateSuccess [{}]", request.getRegistrationId()); } @Override - public void onUpdateFailure(ServerIdentity server, UpdateRequest request, ResponseCode responseCode, String errorMessage, Exception cause) { + public void onUpdateFailure(LwM2mServer server, UpdateRequest request, ResponseCode responseCode, String errorMessage, Exception cause) { log.debug("onUpdateFailure [{}]", request.getRegistrationId()); } @Override - public void onUpdateTimeout(ServerIdentity server, UpdateRequest request) { + public void onUpdateTimeout(LwM2mServer server, UpdateRequest request) { log.debug("onUpdateTimeout [{}]", request.getRegistrationId()); } @Override - public void onDeregistrationStarted(ServerIdentity server, DeregisterRequest request) { + public void onDeregistrationStarted(LwM2mServer server, DeregisterRequest request) { log.debug("onDeregistrationStarted [{}]", request.getRegistrationId()); } @Override - public void onDeregistrationSuccess(ServerIdentity server, DeregisterRequest request) { - log.debug("onDeregistrationStarted [{}]", request.getRegistrationId()); + public void onDeregistrationSuccess(LwM2mServer server, DeregisterRequest request) { + log.debug("onDeregistrationSuccess [{}]", request.getRegistrationId()); } @Override - public void onDeregistrationFailure(ServerIdentity server, DeregisterRequest request, ResponseCode responseCode, String errorMessage, Exception cause) { + public void onDeregistrationFailure(LwM2mServer server, DeregisterRequest request, ResponseCode responseCode, String errorMessage, Exception cause) { log.debug("onDeregistrationFailure [{}] [{}] [{}]", request.getRegistrationId(), responseCode, errorMessage); } @Override - public void onDeregistrationTimeout(ServerIdentity server, DeregisterRequest request) { + public void onDeregistrationTimeout(LwM2mServer server, DeregisterRequest request) { log.debug("onDeregistrationTimeout [{}]", request.getRegistrationId()); } @@ -224,7 +230,6 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable { public void onUnexpectedError(Throwable unexpectedError) { log.debug("onUnexpectedError [{}]", unexpectedError.toString()); } - }; leshanClient.addObserver(observer); @@ -239,17 +244,17 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable { } @Override - public ReadResponse read(ServerIdentity identity, int resourceId) { + public ReadResponse read(LwM2mServer server, int resourceId) { if (supportedResources.contains(resourceId)) { return ReadResponse.success(resourceId, data); } - return super.read(identity, resourceId); + return super.read(server, resourceId); } @SneakyThrows public void send(String data, int resource) { this.data = data; - fireResourcesChange(resource); + fireResourceChange(resource); } @Override From afdfe451d5d6a48497e5a04e04a3399c98be4339 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Mon, 12 May 2025 12:50:47 +0200 Subject: [PATCH 34/38] test fix for givenSslIsTrueAndCredentials_whenGetSslContext_thenVerifySslContext --- .../java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java index bf650af8bb..2438e59106 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java @@ -181,7 +181,8 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest { SslContext actualSslContext = mqttClientConfig.getValue().getSslContext(); assertThat(actualSslContext) .usingRecursiveComparison() - .ignoringFields("ctx", "ctxLock", "sessionContext.context.ctx", "sessionContext.context.ctxLock") + .ignoringFields("ctx", "ctxLock", "sessionContext.context.ctx", "sessionContext.context.ctxLock", + "sslContext") .isEqualTo(SslContextBuilder.forClient().build()); } From be8149a4bc46c2b60ed949aaa5dba176ebe8b171 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 12 May 2025 14:25:24 +0300 Subject: [PATCH 35/38] UI: Fixed help icon color in rule node --- .../rule-node/action/gps-geo-action-config.component.html | 3 ++- .../action/save-to-custom-table-config.component.html | 1 + .../rule-node/action/timeseries-config.component.html | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html index 2d3f230643..8849d60c77 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html @@ -116,11 +116,12 @@ rule-node-config.polygon-definition - help + {{ 'rule-node-config.polygon-definition-required' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html index c9685f2c3f..aee7b9cd83 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html @@ -22,6 +22,7 @@ help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html index 9a6d06255d..f41613c6fb 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html @@ -76,9 +76,10 @@ requiredText="{{ 'rule-node-config.default-ttl-required' | translate }}" minErrorText="{{ 'rule-node-config.min-default-ttl-message' | translate }}" formControlName="defaultTTL"> - help From 64c1a1db0c5c7850262aaf4d1d72a4b9b4204aec Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Wed, 14 May 2025 09:14:54 +0300 Subject: [PATCH 36/38] Revert "test scope mockserver with no dependency to not affect transitive dependencies" This reverts commit d608b87f945337908b4c731a10116eab35b080b0. --- pom.xml | 4 ++-- rule-engine/rule-engine-components/pom.xml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 608a46584a..0260dc09be 100755 --- a/pom.xml +++ b/pom.xml @@ -2233,7 +2233,7 @@ org.mock-server - mockserver-netty-no-dependencies + mockserver-netty ${mock-server.version} test @@ -2245,7 +2245,7 @@ org.mock-server - mockserver-client-java-no-dependencies + mockserver-client-java ${mock-server.version} test diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index b975f7c624..008c422dbf 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -141,12 +141,12 @@ org.mock-server - mockserver-netty-no-dependencies + mockserver-netty test org.mock-server - mockserver-client-java-no-dependencies + mockserver-client-java test From e12b4015b1f568d7b3de12072fd52a9bee19a0a3 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Tue, 20 May 2025 12:06:30 +0300 Subject: [PATCH 37/38] UI: Fixed HP curcuit breaker widget type fqn --- .../widget_bundles/high_performance_scada_energy_system.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/data/json/system/widget_bundles/high_performance_scada_energy_system.json b/application/src/main/data/json/system/widget_bundles/high_performance_scada_energy_system.json index a06ccc0819..bc14e14023 100644 --- a/application/src/main/data/json/system/widget_bundles/high_performance_scada_energy_system.json +++ b/application/src/main/data/json/system/widget_bundles/high_performance_scada_energy_system.json @@ -15,7 +15,7 @@ "hp_wind_turbine_cluster", "hp_fuel_generator", "hp_industrial_fuel_generator", - "hp_circuit_breaker2", + "hp_circuit_breaker", "hp_horizontal_circuit_breaker", "hp_voltage_relay", "hp_3_phase_voltage_relay", From 440087384d3d478011a33756952d0d061c20af2f Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 20 May 2025 12:27:26 +0300 Subject: [PATCH 38/38] deduplication node: fixed retry mechanism --- .../deduplication/TbMsgDeduplicationNode.java | 17 +++-- .../transform/TbMsgDeduplicationNodeTest.java | 76 +++++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java index f7a7c6e4dd..ce5fba102a 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java @@ -64,7 +64,7 @@ import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; @Slf4j public class TbMsgDeduplicationNode implements TbNode { - public static final int TB_MSG_DEDUPLICATION_RETRY_DELAY = 10; + public static final long TB_MSG_DEDUPLICATION_RETRY_DELAY = 10L; private TbMsgDeduplicationNodeConfiguration config; @@ -217,16 +217,17 @@ public class TbMsgDeduplicationNode implements TbNode { } private void enqueueForTellNextWithRetry(TbContext ctx, TbMsg msg, int retryAttempt) { - if (config.getMaxRetries() > retryAttempt) { + if (retryAttempt <= config.getMaxRetries()) { ctx.enqueueForTellNext(msg, TbNodeConnectionType.SUCCESS, - () -> { - log.trace("[{}][{}][{}] Successfully enqueue deduplication result message!", ctx.getSelfId(), msg.getOriginator(), retryAttempt); - }, + () -> log.trace("[{}][{}][{}] Successfully enqueue deduplication result message!", ctx.getSelfId(), msg.getOriginator(), retryAttempt), throwable -> { log.trace("[{}][{}][{}] Failed to enqueue deduplication output message due to: ", ctx.getSelfId(), msg.getOriginator(), retryAttempt, throwable); - ctx.schedule(() -> { - enqueueForTellNextWithRetry(ctx, msg, retryAttempt + 1); - }, TB_MSG_DEDUPLICATION_RETRY_DELAY, TimeUnit.SECONDS); + if (retryAttempt < config.getMaxRetries()) { + ctx.schedule(() -> enqueueForTellNextWithRetry(ctx, msg, retryAttempt + 1), TB_MSG_DEDUPLICATION_RETRY_DELAY, TimeUnit.SECONDS); + } else { + log.trace("[{}][{}] Max retries [{}] exhausted. Dropping deduplication result message [{}]", + ctx.getSelfId(), msg.getOriginator(), config.getMaxRetries(), msg.getId()); + } }); } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java index 4b9cc0aadd..1a0c3431d7 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java @@ -62,11 +62,13 @@ import java.util.function.Consumer; import java.util.stream.Stream; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -411,6 +413,80 @@ public class TbMsgDeduplicationNodeTest extends AbstractRuleNodeUpgradeTest { Assertions.assertEquals(msgWithLatestTsInSecondPack.getType(), actualMsg.getType()); } + @Test + public void given_maxRetriesIsZero_when_enqueueFails_then_noRetriesIsScheduled() throws TbNodeException, ExecutionException, InterruptedException { + int wantedNumberOfTellSelfInvocation = 1; + int msgCount = 1; + awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation); + invokeTellSelf(wantedNumberOfTellSelfInvocation); + + // Given + when(ctx.getQueueName()).thenReturn(DataConstants.MAIN_QUEUE_NAME); + config.setInterval(deduplicationInterval); + config.setStrategy(DeduplicationStrategy.FIRST); + config.setMaxPendingMsgs(msgCount); + config.setMaxRetries(0); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctx, nodeConfiguration); + + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + long currentTimeMillis = System.currentTimeMillis(); + + doAnswer(invocation -> { + Consumer failureCallback = invocation.getArgument(3); + failureCallback.accept(new RuntimeException("Simulated failure")); + return null; + }).when(ctx).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS), any(), any()); + + TbMsg msg = createMsg(deviceId, currentTimeMillis + 1); + node.onMsg(ctx, msg); + + awaitTellSelfLatch.await(); + + verify(ctx).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS), any(), any()); + verify(ctx, never()).schedule(any(), anyLong(), any()); + } + + @Test + public void given_maxRetriesIsSetToOne_when_enqueueFails_then_onlyOneRetryIsScheduled() throws TbNodeException, ExecutionException, InterruptedException { + int wantedNumberOfTellSelfInvocation = 1; + int msgCount = 1; + awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation); + invokeTellSelf(wantedNumberOfTellSelfInvocation); + + when(ctx.getQueueName()).thenReturn(DataConstants.MAIN_QUEUE_NAME); + config.setInterval(deduplicationInterval); + config.setStrategy(DeduplicationStrategy.FIRST); + config.setMaxPendingMsgs(msgCount); + config.setMaxRetries(1); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctx, nodeConfiguration); + + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + long currentTimeMillis = System.currentTimeMillis(); + + doAnswer(invocation -> { + Consumer failureCallback = invocation.getArgument(3); + failureCallback.accept(new RuntimeException("Simulated failure")); + return null; + }).when(ctx).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS), any(), any()); + + TbMsg msg = createMsg(deviceId, currentTimeMillis + 1); + node.onMsg(ctx, msg); + + awaitTellSelfLatch.await(); + + ArgumentCaptor retryRunnableCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(ctx).schedule(retryRunnableCaptor.capture(), eq(TbMsgDeduplicationNode.TB_MSG_DEDUPLICATION_RETRY_DELAY), eq(TimeUnit.SECONDS)); + + retryRunnableCaptor.getValue().run(); + + // Verify total enqueue attempts (initial + retry) + verify(ctx, times(2)).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS), any(), any()); + // No more retries scheduled after reaching maxRetries + verify(ctx).schedule(any(), eq(TbMsgDeduplicationNode.TB_MSG_DEDUPLICATION_RETRY_DELAY), eq(TimeUnit.SECONDS)); + } + // Rule nodes upgrade private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of(