types = widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId());
+ for (WidgetTypeDetails type : types) {
+ if (!type.isDeprecated()) {
+ type.setDeprecated(true);
+ widgetTypeService.saveWidgetType(type);
+ }
+ }
+ widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
+ log.info("Deprecated {} widget type(s) and removed obsolete system widget bundle: {}", types.size(), alias);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_2Migration.java b/application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_2Migration.java
new file mode 100644
index 0000000000..cb5da78070
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_2Migration.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2026 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.install.lts;
+
+import org.springframework.stereotype.Component;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+
+/**
+ * Registration-only migration with no {@link #apply()} (no data migration), kept intentionally.
+ *
+ * {@link LtsMigrationService} selects migrations from the injected {@link LtsMigration} beans, not from the
+ * on-disk {@code data/upgrade/lts//} directories. So this bean is what makes the runner discover
+ * version {@code 4.3.1.2} and execute its {@code data/upgrade/lts/4.3.1.2/schema_update.sql} (which adds
+ * {@code calculated_field.additional_info}). A directory holding a {@code schema_update.sql} but lacking a
+ * matching bean would be silently skipped.
+ *
+ * The dir/bean consistency (both ways) is guarded by a test in {@code LtsMigrationIntegrationTest}.
+ */
+@Component
+@TbCoreComponent
+public class V4_3_1_2Migration implements LtsMigration {
+
+ @Override
+ public String getVersion() {
+ return "4.3.1.2";
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_3Migration.java b/application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_3Migration.java
new file mode 100644
index 0000000000..16e03771a7
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_3Migration.java
@@ -0,0 +1,73 @@
+/**
+ * Copyright © 2016-2026 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.install.lts;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.dao.widget.WidgetsBundleService;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@TbCoreComponent
+@RequiredArgsConstructor
+public class V4_3_1_3Migration implements LtsMigration {
+
+ // Match on the bundle ALIAS, not the JSON filename stem.
+ // The "industrial_widgets" bundle shipped in a misspelled file (industial_widgets.json),
+ // but the alias inside it is correctly spelled.
+ private static final List OBSOLETE_BUNDLE_ALIASES = List.of(
+ "air_quality", "indoor_environment", "industrial_widgets", "outdoor_environment");
+
+ private final WidgetsBundleService widgetsBundleService;
+ private final WidgetTypeService widgetTypeService;
+
+ @Override
+ public String getVersion() {
+ return "4.3.1.3";
+ }
+
+ @Override
+ public void apply() {
+ for (String alias : OBSOLETE_BUNDLE_ALIASES) {
+ deprecateTypesAndDeleteBundle(alias);
+ }
+ }
+
+ // Marks the bundle's widget types as deprecated (keeping the type entities) and deletes ONLY the bundle entity.
+ private void deprecateTypesAndDeleteBundle(String alias) {
+ WidgetsBundle bundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias);
+ if (bundle == null) {
+ return; // already removed — idempotent
+ }
+ List types = widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId());
+ for (WidgetTypeDetails type : types) {
+ if (!type.isDeprecated()) {
+ type.setDeprecated(true);
+ widgetTypeService.saveWidgetType(type);
+ }
+ }
+ widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
+ log.info("Deprecated {} widget type(s) and removed obsolete system widget bundle: {}", types.size(), alias);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
index 2eb8df92fe..464714fde7 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
@@ -28,7 +28,9 @@ import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.service.component.ComponentDiscoveryService;
import org.thingsboard.server.service.component.RuleNodeClassInfo;
+import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.DbUpgradeExecutorService;
+import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.utils.TbNodeUpgradeUtils;
import java.util.ArrayList;
@@ -47,12 +49,13 @@ public class DefaultDataUpdateService implements DataUpdateService {
private final RuleChainService ruleChainService;
private final ComponentDiscoveryService componentDiscoveryService;
private final DbUpgradeExecutorService executorService;
+ private final DatabaseSchemaSettingsService schemaSettingsService;
+ private final LtsMigrationService ltsMigrationService;
@Override
public void updateData() throws Exception {
log.info("Updating data ...");
- //TODO: should be cleaned after each release
-
+ ltsMigrationService.runDataMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion());
log.info("Data updated.");
}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
new file mode 100644
index 0000000000..5da54eb8b5
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
@@ -0,0 +1,1077 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.asset.AssetProfile;
+import org.thingsboard.server.common.data.Dashboard;
+import org.thingsboard.server.common.data.DeviceProfile;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.AssetProfileId;
+import org.thingsboard.server.common.data.id.DashboardId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.IotHubInstalledItemId;
+import org.thingsboard.server.common.data.id.RuleChainId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.iot_hub.AlarmRuleInstalledItemDescriptor;
+import org.thingsboard.server.common.data.iot_hub.CalculatedFieldInstalledItemDescriptor;
+import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor;
+import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor;
+import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem;
+import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor;
+import org.thingsboard.server.common.data.iot_hub.RuleChainInstalledItemDescriptor;
+import org.thingsboard.server.common.data.iot_hub.SolutionTemplateInstalledItemDescriptor;
+import org.thingsboard.server.common.data.iot_hub.WidgetInstalledItemDescriptor;
+import org.thingsboard.server.exception.EntitiesLimitExceededException;
+import org.thingsboard.server.service.solutions.SolutionService;
+import org.thingsboard.server.service.solutions.data.solution.SolutionInstallResponse;
+import org.thingsboard.server.common.data.rule.RuleChain;
+import org.thingsboard.server.common.data.rule.NodeConnectionInfo;
+import org.thingsboard.server.common.data.rule.RuleChainMetaData;
+import org.thingsboard.server.common.data.rule.RuleNode;
+import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.dao.asset.AssetProfileService;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
+import org.thingsboard.server.dao.dashboard.DashboardService;
+import org.thingsboard.server.dao.device.DeviceProfileService;
+import org.thingsboard.server.dao.device.DeviceService;
+import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService;
+import org.thingsboard.server.dao.rule.RuleChainService;
+import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+import org.thingsboard.server.service.entitiy.asset.profile.TbAssetProfileService;
+import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
+import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService;
+import org.thingsboard.server.service.entitiy.device.TbDeviceService;
+import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService;
+import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService;
+import org.thingsboard.server.service.rule.TbRuleChainService;
+import org.thingsboard.server.service.security.model.SecurityUser;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+@Service
+@TbCoreComponent
+@RequiredArgsConstructor
+@Slf4j
+public class DefaultIotHubService implements IotHubService {
+
+ private static final String ACTION_INSTALL = "install";
+ private static final String ACTION_UPDATE = "update";
+ private static final String SECTION_PACKAGE_DATA = "package data";
+ private static final String SECTION_RULE_CHAIN_METADATA = "rule chain metadata";
+
+ private static final String ITEM_TYPE_WIDGET = "widget";
+ private static final String ITEM_TYPE_DASHBOARD = "dashboard";
+ private static final String ITEM_TYPE_CALCULATED_FIELD = "calculated field";
+ private static final String ITEM_TYPE_ALARM_RULE = "alarm rule";
+ private static final String ITEM_TYPE_RULE_CHAIN = "rule chain";
+ private static final String ITEM_TYPE_DEVICE_PROFILE = "device profile";
+
+ private final IotHubRestClient iotHubRestClient;
+ private final TbWidgetTypeService tbWidgetTypeService;
+ private final TbDashboardService tbDashboardService;
+ private final TbCalculatedFieldService tbCalculatedFieldService;
+ private final RuleChainService ruleChainService;
+ private final TbRuleChainService tbRuleChainService;
+ private final TbDeviceProfileService tbDeviceProfileService;
+ private final AssetProfileService assetProfileService;
+ private final TbAssetProfileService tbAssetProfileService;
+ private final IotHubInstalledItemService iotHubInstalledItemService;
+ private final WidgetTypeService widgetTypeService;
+ private final DashboardService dashboardService;
+ private final CalculatedFieldService calculatedFieldService;
+ private final DeviceProfileService deviceProfileService;
+ private final DeviceService deviceService;
+ private final TbDeviceService tbDeviceService;
+ private final SolutionService solutionService;
+
+ // Field names of the marketplace version JSON payload. Both the install path and the
+ // install-plan resolver parse the same shape, so the contract lives here in one place.
+ private static final String FIELD_ID = "id";
+ private static final String FIELD_ITEM_ID = "itemId";
+ private static final String FIELD_TYPE = "type";
+ private static final String FIELD_NAME = "name";
+ private static final String FIELD_VERSION = "version";
+ private static final String FIELD_RELATED_ITEMS = "relatedItems";
+
+ @Override
+ public InstallItemVersionResult installItemVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) {
+ TenantId tenantId = user.getTenantId();
+ log.info("[{}] Installing IoT Hub item version: {}", tenantId, versionId);
+
+ JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
+ if (versionInfo == null) {
+ throw new IllegalArgumentException("Failed to get version info from IoT Hub");
+ }
+
+ try {
+ IotHubInstalledItem installedItem = doInstallVersion(user, versionId, data, request);
+ return InstallItemVersionResult.success(installedItem.getDescriptor());
+ } catch (Exception e) {
+ log.error("[{}] Failed to install IoT Hub item version: {}", tenantId, versionId, e);
+ if (e instanceof EntitiesLimitExceededException el) {
+ throw el;
+ }
+ return InstallItemVersionResult.error(e.getMessage());
+ }
+ }
+
+ /**
+ * Fetch + apply a single marketplace version, persist the installed-item record, and ping
+ * the marketplace install counter. Throws on failure so callers (cascade install) can roll back.
+ * Package-private so the cascade-install / rollback orchestration can be unit-tested in isolation.
+ */
+ IotHubInstalledItem doInstallVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) throws Exception {
+ TenantId tenantId = user.getTenantId();
+ JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
+ String itemType = versionInfo.get(FIELD_TYPE).asText();
+ String itemName = versionInfo.get(FIELD_NAME).asText();
+ UUID itemId = UUID.fromString(versionInfo.get(FIELD_ITEM_ID).asText());
+ String version = versionInfo.get(FIELD_VERSION).asText();
+ log.debug("[{}] Fetched version info: {} (type: {})", tenantId, itemName, itemType);
+
+ byte[] fileData = iotHubRestClient.getVersionFileData(versionId);
+ log.debug("[{}] Fetched file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0);
+
+ IotHubInstalledItemDescriptor descriptor = switch (itemType) {
+ case "WIDGET" -> installWidget(user, tenantId, fileData);
+ case "DASHBOARD" -> installDashboard(user, tenantId, fileData);
+ case "CALCULATED_FIELD" -> installCalculatedField(user, tenantId, fileData, data);
+ case "ALARM_RULE" -> installAlarmRule(user, tenantId, fileData, data);
+ case "RULE_CHAIN" -> installRuleChain(user, tenantId, fileData, data);
+ case "DEVICE" -> installDeviceProfile(user, tenantId, fileData);
+ case "SOLUTION_TEMPLATE" -> installSolution(user, tenantId, fileData, request);
+ default -> throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType);
+ };
+
+ IotHubInstalledItem installedItem = new IotHubInstalledItem();
+ installedItem.setTenantId(tenantId);
+ installedItem.setItemId(itemId);
+ installedItem.setItemVersionId(UUID.fromString(versionId));
+ installedItem.setItemName(itemName);
+ installedItem.setItemType(itemType);
+ installedItem.setVersion(version);
+ installedItem.setDescriptor(descriptor);
+ try {
+ installedItem = iotHubInstalledItemService.save(tenantId, installedItem);
+ } catch (Exception e) {
+ // Tracking save failed after the entity was already created locally — compensate by
+ // deleting the orphaned entity so the user is not left with an installed item that
+ // IoT Hub update/delete flows cannot see.
+ log.error("[{}] Failed to save IoT Hub installed-item tracking row for {} ({}); rolling back created entity",
+ tenantId, itemName, itemType, e);
+ try {
+ deleteEntityForDescriptor(user, tenantId, descriptor, null);
+ } catch (Exception rb) {
+ log.error("[{}] Compensating delete after failed tracking save threw — entity may remain orphaned",
+ tenantId, rb);
+ }
+ throw e;
+ }
+
+ try {
+ iotHubRestClient.reportVersionInstalled(versionId);
+ } catch (Exception e) {
+ // Counter ping is best-effort — do not fail the install if it errors.
+ log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage());
+ }
+ log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType);
+ return installedItem;
+ }
+
+ private WidgetInstalledItemDescriptor installWidget(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception {
+ WidgetTypeDetails widgetTypeDetails;
+ try {
+ widgetTypeDetails = JacksonUtil.fromString(new String(fileData), WidgetTypeDetails.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_INSTALL, ITEM_TYPE_WIDGET, e);
+ }
+ widgetTypeDetails.setId(null);
+ widgetTypeDetails.setTenantId(tenantId);
+ WidgetTypeDetails saved = tbWidgetTypeService.save(widgetTypeDetails, false, user);
+ log.debug("[{}] Widget installed: {}", tenantId, saved.getName());
+ WidgetInstalledItemDescriptor descriptor = new WidgetInstalledItemDescriptor();
+ descriptor.setWidgetTypeId(saved.getId());
+ return descriptor;
+ }
+
+ private DashboardInstalledItemDescriptor installDashboard(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception {
+ Dashboard dashboard;
+ try {
+ dashboard = JacksonUtil.fromString(new String(fileData), Dashboard.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_INSTALL, ITEM_TYPE_DASHBOARD, e);
+ }
+ dashboard.setId(null);
+ dashboard.setTenantId(tenantId);
+ Dashboard saved = tbDashboardService.save(dashboard, user);
+ log.debug("[{}] Dashboard installed: {}", tenantId, saved.getTitle());
+ DashboardInstalledItemDescriptor descriptor = new DashboardInstalledItemDescriptor();
+ descriptor.setDashboardId(saved.getId());
+ return descriptor;
+ }
+
+ private CalculatedFieldInstalledItemDescriptor installCalculatedField(SecurityUser user, TenantId tenantId, byte[] fileData, JsonNode data) throws Exception {
+ CalculatedField calculatedField;
+ try {
+ calculatedField = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_INSTALL, ITEM_TYPE_CALCULATED_FIELD, e);
+ }
+ calculatedField.setId(null);
+ calculatedField.setTenantId(tenantId);
+ if (data != null && data.has("entityId")) {
+ EntityId entityId = JacksonUtil.treeToValue(data.get("entityId"), EntityId.class);
+ calculatedField.setEntityId(entityId);
+ }
+ CalculatedField saved = tbCalculatedFieldService.save(calculatedField, user);
+ log.debug("[{}] Calculated field installed: {}", tenantId, saved.getName());
+ CalculatedFieldInstalledItemDescriptor descriptor = new CalculatedFieldInstalledItemDescriptor();
+ descriptor.setCalculatedFieldId(saved.getId());
+ descriptor.setEntityId(saved.getEntityId());
+ return descriptor;
+ }
+
+ private AlarmRuleInstalledItemDescriptor installAlarmRule(SecurityUser user, TenantId tenantId, byte[] fileData, JsonNode data) throws Exception {
+ CalculatedField alarmRule;
+ try {
+ alarmRule = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_INSTALL, ITEM_TYPE_ALARM_RULE, e);
+ }
+ if (alarmRule.getType() != CalculatedFieldType.ALARM) {
+ throw new Exception("Expected ALARM-type calculated field for ALARM_RULE install; got: " + alarmRule.getType());
+ }
+ alarmRule.setId(null);
+ alarmRule.setTenantId(tenantId);
+ if (data != null && data.has("entityId")) {
+ EntityId entityId = JacksonUtil.treeToValue(data.get("entityId"), EntityId.class);
+ alarmRule.setEntityId(entityId);
+ }
+ CalculatedField saved = tbCalculatedFieldService.save(alarmRule, user);
+ log.debug("[{}] Alarm rule installed: {}", tenantId, saved.getName());
+ AlarmRuleInstalledItemDescriptor descriptor = new AlarmRuleInstalledItemDescriptor();
+ descriptor.setCalculatedFieldId(saved.getId());
+ descriptor.setEntityId(saved.getEntityId());
+ return descriptor;
+ }
+
+ private RuleChainInstalledItemDescriptor installRuleChain(
+ SecurityUser user, TenantId tenantId, byte[] fileData, JsonNode data) throws Exception {
+ JsonNode json = JacksonUtil.toJsonNode(new String(fileData));
+
+ RuleChain ruleChain;
+ try {
+ ruleChain = JacksonUtil.fromString(json.get("ruleChain").toString(), RuleChain.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_INSTALL, ITEM_TYPE_RULE_CHAIN, e);
+ }
+
+ RuleChainMetaData metadata;
+ try {
+ metadata = JacksonUtil.fromString(json.get("metadata").toString(), RuleChainMetaData.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_INSTALL, ITEM_TYPE_RULE_CHAIN, SECTION_RULE_CHAIN_METADATA, e);
+ }
+
+ ruleChain.setId(null);
+ ruleChain.setTenantId(tenantId);
+ RuleChain savedRuleChain = ruleChainService.saveRuleChain(ruleChain);
+
+ metadata.setRuleChainId(savedRuleChain.getId());
+ metadata.setVersion(savedRuleChain.getVersion());
+ ruleChainService.saveRuleChainMetaData(tenantId, metadata, tbRuleChainService::updateRuleNodeConfiguration);
+
+ log.debug("[{}] Rule chain installed: {}", tenantId, savedRuleChain.getName());
+
+ RuleChainInstalledItemDescriptor descriptor = new RuleChainInstalledItemDescriptor();
+ descriptor.setRuleChainId(savedRuleChain.getId());
+
+ if (data != null && data.has("entityId") && !data.get("entityId").isNull()) {
+ EntityId entityId = JacksonUtil.treeToValue(data.get("entityId"), EntityId.class);
+ setAsDefaultRuleChain(user, tenantId, entityId, savedRuleChain.getId());
+ descriptor.setTargetProfileId(entityId);
+ }
+
+ return descriptor;
+ }
+
+ private void setAsDefaultRuleChain(SecurityUser user, TenantId tenantId,
+ EntityId entityId, RuleChainId ruleChainId) throws Exception {
+ switch (entityId.getEntityType()) {
+ case DEVICE_PROFILE -> {
+ DeviceProfile profile = deviceProfileService.findDeviceProfileById(
+ tenantId, (DeviceProfileId) entityId);
+ if (profile == null) {
+ throw new IllegalArgumentException(
+ "Device profile not found: " + entityId.getId());
+ }
+ profile.setDefaultRuleChainId(ruleChainId);
+ tbDeviceProfileService.save(profile, user);
+ log.debug("[{}] Set rule chain {} as default on device profile {}",
+ tenantId, ruleChainId.getId(), entityId.getId());
+ }
+ case ASSET_PROFILE -> {
+ AssetProfile profile = assetProfileService.findAssetProfileById(
+ tenantId, (AssetProfileId) entityId);
+ if (profile == null) {
+ throw new IllegalArgumentException(
+ "Asset profile not found: " + entityId.getId());
+ }
+ profile.setDefaultRuleChainId(ruleChainId);
+ tbAssetProfileService.save(profile, user);
+ log.debug("[{}] Set rule chain {} as default on asset profile {}",
+ tenantId, ruleChainId.getId(), entityId.getId());
+ }
+ default -> throw new IllegalArgumentException(
+ "Rule chain can only be set as default on device or asset profile, got: "
+ + entityId.getEntityType());
+ }
+ }
+
+ private DeviceInstalledItemDescriptor installDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception {
+ DeviceProfile deviceProfile;
+ try {
+ deviceProfile = JacksonUtil.fromString(new String(fileData), DeviceProfile.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_INSTALL, ITEM_TYPE_DEVICE_PROFILE, e);
+ }
+ deviceProfile.setId(null);
+ deviceProfile.setTenantId(tenantId);
+ tbDeviceProfileService.save(deviceProfile, user);
+ log.debug("[{}] Device profile installed: {}", tenantId, deviceProfile.getName());
+ return new DeviceInstalledItemDescriptor();
+ }
+
+ private SolutionTemplateInstalledItemDescriptor installSolution(SecurityUser user, TenantId tenantId, byte[] fileData, HttpServletRequest request) throws Exception {
+ SolutionInstallResponse response = solutionService.installSolution(user, tenantId, fileData, request);
+ if (!response.isSuccess()) {
+ throw new RuntimeException(response.getDetails());
+ }
+ SolutionTemplateInstalledItemDescriptor descriptor = new SolutionTemplateInstalledItemDescriptor();
+ descriptor.setCreatedEntityIds(response.getCreatedEntityIds());
+ descriptor.setTenantTelemetryKeys(response.getTenantTelemetryKeys());
+ descriptor.setTenantAttributeKeys(response.getTenantAttributeKeys());
+ descriptor.setDashboardId(response.getDashboardId());
+ descriptor.setPublicId(response.getPublicId());
+ descriptor.setMainDashboardPublic(response.isMainDashboardPublic());
+ descriptor.setDetails(response.getDetails());
+ return descriptor;
+ }
+
+ @Override
+ public UpdateItemVersionResult updateItemVersion(SecurityUser user, IotHubInstalledItemId installedItemId, String versionId, boolean force, HttpServletRequest request) {
+ TenantId tenantId = user.getTenantId();
+ log.info("[{}] Updating IoT Hub installed item {} to version: {}", tenantId, installedItemId, versionId);
+
+ try {
+ IotHubInstalledItem installedItem = iotHubInstalledItemService.findById(tenantId, installedItemId);
+ if (installedItem == null) {
+ throw new IllegalArgumentException("Installed item not found");
+ }
+
+ String itemType = installedItem.getItemType();
+ IotHubInstalledItemDescriptor descriptor = installedItem.getDescriptor();
+
+ // Skip checksum validation for solution templates
+ if (!"SOLUTION_TEMPLATE".equals(itemType)) {
+ JsonNode installedVersionInfo = iotHubRestClient.getVersionInfo(installedItem.getItemVersionId().toString());
+ if (installedVersionInfo == null) {
+ throw new IllegalArgumentException("Failed to get installed version info from IoT Hub");
+ }
+ String installedChecksum = installedVersionInfo.has("checksum") ? installedVersionInfo.get("checksum").asText() : null;
+ log.info("[{}] Installed version info: name={}, version={}, checksum={}", tenantId, installedItem.getItemName(), installedItem.getVersion(), installedChecksum);
+
+ if (!force) {
+ String entityChecksum = calculateEntityChecksum(tenantId, installedItem);
+ boolean entityModified = installedChecksum != null && !installedChecksum.equals(entityChecksum);
+ log.info("[{}] Entity checksum: {}, modified: {}", tenantId, entityChecksum, entityModified);
+
+ if (entityModified) {
+ return UpdateItemVersionResult.entityModified();
+ }
+ }
+ }
+
+ JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
+
+ if (versionInfo == null) {
+ throw new IllegalArgumentException("Failed to get version info from IoT Hub");
+ }
+
+ String updateItemType = versionInfo.get("type").asText();
+ if (!itemType.equals(updateItemType)) {
+ throw new IllegalArgumentException("Installed item type does not match the new version's item type.");
+ }
+
+ String itemName = versionInfo.get("name").asText();
+ String version = versionInfo.get("version").asText();
+
+ byte[] fileData = iotHubRestClient.getVersionFileData(versionId);
+ log.info("[{}] Fetched update file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0);
+
+ switch (itemType) {
+ case "WIDGET" -> updateWidget(user, tenantId, (WidgetInstalledItemDescriptor) descriptor, fileData);
+ case "DASHBOARD" -> updateDashboard(user, tenantId, (DashboardInstalledItemDescriptor) descriptor, fileData);
+ case "CALCULATED_FIELD" -> updateCalculatedField(user, tenantId, (CalculatedFieldInstalledItemDescriptor) descriptor, fileData);
+ case "ALARM_RULE" -> updateAlarmRule(user, tenantId, (AlarmRuleInstalledItemDescriptor) descriptor, fileData);
+ case "RULE_CHAIN" -> updateRuleChain(tenantId, (RuleChainInstalledItemDescriptor) descriptor, fileData);
+ case "DEVICE" -> {
+ DeviceInstalledItemDescriptor dd = (DeviceInstalledItemDescriptor) descriptor;
+ deleteDevicePackageEntities(tenantId, dd.getCreatedEntityIds(), user);
+ dd.setCreatedEntityIds(null);
+ dd.setDashboardId(null);
+ }
+ case "SOLUTION_TEMPLATE" -> {
+ SolutionTemplateInstalledItemDescriptor stDescriptor = (SolutionTemplateInstalledItemDescriptor) descriptor;
+ solutionService.deleteSolution(tenantId, stDescriptor, user);
+ SolutionInstallResponse response = solutionService.installSolution(user, tenantId, fileData, request);
+ if (!response.isSuccess()) {
+ throw new RuntimeException(response.getDetails());
+ }
+ stDescriptor.setCreatedEntityIds(response.getCreatedEntityIds());
+ stDescriptor.setTenantTelemetryKeys(response.getTenantTelemetryKeys());
+ stDescriptor.setTenantAttributeKeys(response.getTenantAttributeKeys());
+ stDescriptor.setDashboardId(response.getDashboardId());
+ stDescriptor.setPublicId(response.getPublicId());
+ stDescriptor.setMainDashboardPublic(response.isMainDashboardPublic());
+ stDescriptor.setDetails(response.getDetails());
+ }
+ default -> throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType);
+ }
+
+ installedItem.setItemVersionId(UUID.fromString(versionId));
+ installedItem.setItemName(itemName);
+ installedItem.setVersion(version);
+ iotHubInstalledItemService.save(tenantId, installedItem);
+
+ log.info("[{}] Successfully updated IoT Hub item {} to version: {}", tenantId, itemName, version);
+
+ return UpdateItemVersionResult.success(installedItem.getDescriptor());
+ } catch (Exception e) {
+ log.error("[{}] Failed to update IoT Hub installed item {} to version: {}", tenantId, installedItemId, versionId, e);
+ return UpdateItemVersionResult.error(e.getMessage());
+ }
+ }
+
+ private void updateWidget(SecurityUser user, TenantId tenantId, WidgetInstalledItemDescriptor descriptor, byte[] fileData) throws Exception {
+ WidgetTypeDetails newWidgetType;
+ try {
+ newWidgetType = JacksonUtil.fromString(new String(fileData), WidgetTypeDetails.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_UPDATE, ITEM_TYPE_WIDGET, e);
+ }
+ WidgetTypeDetails existing = widgetTypeService.findWidgetTypeDetailsById(tenantId, descriptor.getWidgetTypeId());
+ if (existing == null) {
+ throw new Exception("Widget not found for update");
+ }
+ existing.setName(newWidgetType.getName());
+ existing.setDescriptor(newWidgetType.getDescriptor());
+ tbWidgetTypeService.save(existing, false, user);
+ }
+
+ private void updateDashboard(SecurityUser user, TenantId tenantId, DashboardInstalledItemDescriptor descriptor, byte[] fileData) throws Exception {
+ Dashboard newDashboard;
+ try {
+ newDashboard = JacksonUtil.fromString(new String(fileData), Dashboard.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_UPDATE, ITEM_TYPE_DASHBOARD, e);
+ }
+ Dashboard existing = dashboardService.findDashboardById(tenantId, descriptor.getDashboardId());
+ if (existing == null) {
+ throw new Exception("Dashboard not found for update");
+ }
+ existing.setTitle(newDashboard.getTitle());
+ existing.setConfiguration(newDashboard.getConfiguration());
+ tbDashboardService.save(existing, user);
+ }
+
+ private void updateCalculatedField(SecurityUser user, TenantId tenantId, CalculatedFieldInstalledItemDescriptor descriptor, byte[] fileData) throws Exception {
+ CalculatedField newCf;
+ try {
+ newCf = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_UPDATE, ITEM_TYPE_CALCULATED_FIELD, e);
+ }
+ CalculatedField existing = calculatedFieldService.findById(tenantId, descriptor.getCalculatedFieldId());
+ if (existing == null) {
+ throw new Exception("Calculated field not found for update");
+ }
+ existing.setName(newCf.getName());
+ existing.setType(newCf.getType());
+ existing.setConfiguration(newCf.getConfiguration());
+ tbCalculatedFieldService.save(existing, user);
+ }
+
+ private void updateAlarmRule(SecurityUser user, TenantId tenantId, AlarmRuleInstalledItemDescriptor descriptor, byte[] fileData) throws Exception {
+ CalculatedField newCf;
+ try {
+ newCf = JacksonUtil.fromString(new String(fileData), CalculatedField.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_UPDATE, ITEM_TYPE_ALARM_RULE, e);
+ }
+ if (newCf.getType() != CalculatedFieldType.ALARM) {
+ throw new Exception("Expected ALARM-type calculated field for ALARM_RULE update; got: " + newCf.getType());
+ }
+ CalculatedField existing = calculatedFieldService.findById(tenantId, descriptor.getCalculatedFieldId());
+ if (existing == null) {
+ throw new Exception("Alarm rule not found for update");
+ }
+ existing.setName(newCf.getName());
+ existing.setType(newCf.getType());
+ existing.setConfiguration(newCf.getConfiguration());
+ tbCalculatedFieldService.save(existing, user);
+ }
+
+ private void updateRuleChain(TenantId tenantId, RuleChainInstalledItemDescriptor descriptor, byte[] fileData) throws Exception {
+ JsonNode json = JacksonUtil.toJsonNode(new String(fileData));
+ RuleChainMetaData metadata;
+ try {
+ metadata = JacksonUtil.fromString(json.get("metadata").toString(), RuleChainMetaData.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_UPDATE, ITEM_TYPE_RULE_CHAIN, SECTION_RULE_CHAIN_METADATA, e);
+ }
+ RuleChain existing = ruleChainService.findRuleChainById(tenantId, descriptor.getRuleChainId());
+ if (existing == null) {
+ throw new Exception("Rule chain not found for update");
+ }
+ RuleChain newRuleChain;
+ try {
+ newRuleChain = JacksonUtil.fromString(json.get("ruleChain").toString(), RuleChain.class, true);
+ } catch (Exception e) {
+ throw parseFailure(ACTION_UPDATE, ITEM_TYPE_RULE_CHAIN, e);
+ }
+ existing.setName(newRuleChain.getName());
+ RuleChain savedRuleChain = ruleChainService.saveRuleChain(existing);
+ metadata.setRuleChainId(savedRuleChain.getId());
+ metadata.setVersion(savedRuleChain.getVersion());
+ ruleChainService.saveRuleChainMetaData(tenantId, metadata, tbRuleChainService::updateRuleNodeConfiguration);
+ }
+
+ private String calculateEntityChecksum(TenantId tenantId, IotHubInstalledItem installedItem) {
+ IotHubInstalledItemDescriptor descriptor = installedItem.getDescriptor();
+ if (descriptor instanceof WidgetInstalledItemDescriptor wd) {
+ return calculateWidgetChecksum(tenantId, wd);
+ } else if (descriptor instanceof DashboardInstalledItemDescriptor dd) {
+ return calculateDashboardChecksum(tenantId, dd);
+ } else if (descriptor instanceof CalculatedFieldInstalledItemDescriptor cd) {
+ return calculateCalculatedFieldChecksum(tenantId, cd.getCalculatedFieldId());
+ } else if (descriptor instanceof AlarmRuleInstalledItemDescriptor ad) {
+ return calculateCalculatedFieldChecksum(tenantId, ad.getCalculatedFieldId());
+ } else if (descriptor instanceof RuleChainInstalledItemDescriptor rd) {
+ return calculateRuleChainChecksum(tenantId, rd);
+ }
+ return null;
+ }
+
+ private String calculateCalculatedFieldChecksum(TenantId tenantId, CalculatedFieldId calculatedFieldId) {
+ CalculatedField cf = calculatedFieldService.findById(tenantId, calculatedFieldId);
+ if (cf == null) {
+ return null;
+ }
+ String content = (cf.getName() != null ? cf.getName() : "") +
+ (cf.getType() != null ? cf.getType().name() : "") +
+ (cf.getConfiguration() != null ? JacksonUtil.valueToTree(cf.getConfiguration()).toString() : "");
+ return sha256(content);
+ }
+
+ private String calculateDashboardChecksum(TenantId tenantId, DashboardInstalledItemDescriptor descriptor) {
+ Dashboard dashboard = dashboardService.findDashboardById(tenantId, descriptor.getDashboardId());
+ if (dashboard == null) {
+ return null;
+ }
+ String content = (dashboard.getTitle() != null ? dashboard.getTitle() : "") +
+ (dashboard.getConfiguration() != null ? dashboard.getConfiguration().toString() : "");
+ return sha256(content);
+ }
+
+ private String calculateWidgetChecksum(TenantId tenantId, WidgetInstalledItemDescriptor descriptor) {
+ WidgetTypeDetails widgetType = widgetTypeService.findWidgetTypeDetailsById(tenantId, descriptor.getWidgetTypeId());
+ if (widgetType == null) {
+ return null;
+ }
+ String content = (widgetType.getFqn() != null ? widgetType.getFqn() : "") +
+ (widgetType.getName() != null ? widgetType.getName() : "") +
+ (widgetType.getDescriptor() != null ? widgetType.getDescriptor().toString() : "");
+ return sha256(content);
+ }
+
+ private String calculateRuleChainChecksum(TenantId tenantId, RuleChainInstalledItemDescriptor descriptor) {
+ RuleChain ruleChain = ruleChainService.findRuleChainById(tenantId, descriptor.getRuleChainId());
+ if (ruleChain == null) {
+ return null;
+ }
+ RuleChainMetaData metadata = ruleChainService.loadRuleChainMetaData(tenantId, descriptor.getRuleChainId());
+ StringBuilder content = new StringBuilder();
+ content.append(ruleChain.getName() != null ? ruleChain.getName() : "");
+ content.append(ruleChain.getType() != null ? ruleChain.getType().name() : "");
+ if (metadata.getNodes() != null) {
+ for (RuleNode node : metadata.getNodes()) {
+ content.append(node.getType() != null ? node.getType() : "");
+ content.append(node.getName() != null ? node.getName() : "");
+ content.append(node.getConfiguration() != null ? node.getConfiguration().toString() : "");
+ }
+ }
+ if (metadata.getConnections() != null) {
+ for (NodeConnectionInfo conn : metadata.getConnections()) {
+ content.append(conn.getFromIndex());
+ content.append(conn.getToIndex());
+ content.append(conn.getType() != null ? conn.getType() : "");
+ }
+ }
+ return sha256(content.toString());
+ }
+
+ private static String sha256(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to calculate SHA-256", e);
+ }
+ }
+
+ @Override
+ public InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor) {
+ TenantId tenantId = user.getTenantId();
+ log.info("[{}] Registering device package install for version: {}", tenantId, versionId);
+
+ try {
+ JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
+
+ if (versionInfo == null) {
+ throw new IllegalArgumentException("Failed to get version info from IoT Hub");
+ }
+
+ String itemName = versionInfo.get("name").asText();
+ UUID itemId = UUID.fromString(versionInfo.get("itemId").asText());
+ String version = versionInfo.get("version").asText();
+
+ IotHubInstalledItem installedItem = new IotHubInstalledItem();
+ installedItem.setTenantId(tenantId);
+ installedItem.setItemId(itemId);
+ installedItem.setItemVersionId(UUID.fromString(versionId));
+ installedItem.setItemName(itemName);
+ installedItem.setItemType("DEVICE");
+ installedItem.setVersion(version);
+ installedItem.setDescriptor(descriptor);
+ try {
+ iotHubInstalledItemService.save(tenantId, installedItem);
+ } catch (Exception e) {
+ log.error("[{}] Failed to save IoT Hub installed-item tracking row for device package {} (version {}); rolling back created entities",
+ tenantId, itemName, version, e);
+ try {
+ deleteEntityForDescriptor(user, tenantId, descriptor, null);
+ } catch (Exception rb) {
+ log.error("[{}] Compensating delete after failed tracking save threw — device package entities may remain orphaned",
+ tenantId, rb);
+ }
+ throw e;
+ }
+
+ try {
+ iotHubRestClient.reportVersionInstalled(versionId);
+ } catch (Exception e) {
+ // Counter ping is best-effort — do not fail the install if it errors.
+ log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage());
+ }
+ log.info("[{}] Registered device package install: {} (version {})", tenantId, itemName, version);
+
+ return InstallItemVersionResult.success(descriptor);
+ } catch (Exception e) {
+ log.error("[{}] Failed to register device package install: {}", tenantId, versionId, e);
+ return InstallItemVersionResult.error(e.getMessage());
+ }
+ }
+
+ private void deleteDevicePackageEntities(TenantId tenantId, List createdEntityIds, SecurityUser user) {
+ if (createdEntityIds == null || createdEntityIds.isEmpty()) {
+ return;
+ }
+ List reversed = new ArrayList<>(createdEntityIds);
+ Collections.reverse(reversed);
+ for (EntityId entityId : reversed) {
+ try {
+ deleteDevicePackageEntity(tenantId, entityId, user);
+ } catch (Exception e) {
+ log.error("[{}] Failed to delete device package entity: {}", tenantId, entityId, e);
+ }
+ }
+ }
+
+ private void deleteDevicePackageEntity(TenantId tenantId, EntityId entityId, SecurityUser user) {
+ switch (entityId.getEntityType()) {
+ case DEVICE -> {
+ var device = deviceService.findDeviceById(tenantId, new DeviceId(entityId.getId()));
+ if (device != null) tbDeviceService.delete(device, user);
+ }
+ case DEVICE_PROFILE -> {
+ var profile = deviceProfileService.findDeviceProfileById(tenantId, new DeviceProfileId(entityId.getId()));
+ if (profile != null) tbDeviceProfileService.delete(profile, user);
+ }
+ case DASHBOARD -> {
+ var dashboard = dashboardService.findDashboardById(tenantId, new DashboardId(entityId.getId()));
+ if (dashboard != null) tbDashboardService.delete(dashboard, user);
+ }
+ case RULE_CHAIN -> {
+ var ruleChain = ruleChainService.findRuleChainById(tenantId, new RuleChainId(entityId.getId()));
+ if (ruleChain != null) tbRuleChainService.delete(ruleChain, user);
+ }
+ default -> log.warn("[{}] Unsupported entity type for device package delete: {}", tenantId, entityId.getEntityType());
+ }
+ }
+
+ @Override
+ public void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId) {
+ TenantId tenantId = user.getTenantId();
+ IotHubInstalledItem installedItem = iotHubInstalledItemService.findById(tenantId, installedItemId);
+ if (installedItem == null) {
+ throw new IllegalArgumentException("Installed item not found");
+ }
+
+ deleteEntityForDescriptor(user, tenantId, installedItem.getDescriptor(), installedItemId);
+
+ iotHubInstalledItemService.deleteById(tenantId, installedItemId);
+ log.info("[{}] Deleted installed IoT Hub item: {}", tenantId, installedItem.getItemName());
+ }
+
+ /**
+ * Per-type dispatcher that removes the local entities a marketplace install created. Shared by
+ * {@link #deleteInstalledItem} and by the tracking-row compensation path in
+ * {@link #doInstallVersion} so a failed tracking save does not leave the entity orphaned.
+ */
+ private void deleteEntityForDescriptor(SecurityUser user, TenantId tenantId,
+ IotHubInstalledItemDescriptor descriptor,
+ IotHubInstalledItemId installedItemId) {
+ if (descriptor instanceof WidgetInstalledItemDescriptor wd) {
+ WidgetTypeDetails widgetType = widgetTypeService.findWidgetTypeDetailsById(tenantId, wd.getWidgetTypeId());
+ if (widgetType != null) {
+ tbWidgetTypeService.delete(widgetType, user);
+ }
+ } else if (descriptor instanceof DashboardInstalledItemDescriptor dd) {
+ Dashboard dashboard = dashboardService.findDashboardById(tenantId, dd.getDashboardId());
+ if (dashboard != null) {
+ tbDashboardService.delete(dashboard, user);
+ }
+ } else if (descriptor instanceof CalculatedFieldInstalledItemDescriptor cd) {
+ CalculatedField calculatedField = calculatedFieldService.findById(tenantId, cd.getCalculatedFieldId());
+ if (calculatedField != null) {
+ tbCalculatedFieldService.delete(calculatedField, user);
+ }
+ } else if (descriptor instanceof AlarmRuleInstalledItemDescriptor ad) {
+ CalculatedField alarmRule = calculatedFieldService.findById(tenantId, ad.getCalculatedFieldId());
+ if (alarmRule != null) {
+ tbCalculatedFieldService.delete(alarmRule, user);
+ }
+ } else if (descriptor instanceof RuleChainInstalledItemDescriptor rd) {
+ if (rd.getRuleChainId() != null) {
+ RuleChain ruleChain = ruleChainService.findRuleChainById(tenantId, rd.getRuleChainId());
+ if (ruleChain != null) {
+ tbRuleChainService.delete(ruleChain, user);
+ }
+ }
+ } else if (descriptor instanceof DeviceInstalledItemDescriptor dd) {
+ deleteDevicePackageEntities(tenantId, dd.getCreatedEntityIds(), user);
+ } else if (descriptor instanceof SolutionTemplateInstalledItemDescriptor st) {
+ try {
+ solutionService.deleteSolution(tenantId, st, user);
+ } catch (ThingsboardException e) {
+ log.error("[{}] Failed to delete solution for installed item {}", tenantId, installedItemId, e);
+ }
+ }
+ }
+
+ @Override
+ public InstallPlan resolveInstallPlan(SecurityUser user, String versionId) {
+ TenantId tenantId = user.getTenantId();
+ log.debug("[{}] Resolving install plan for version: {}", tenantId, versionId);
+
+ JsonNode rootVersion = iotHubRestClient.getVersionInfo(versionId);
+ if (rootVersion == null) {
+ throw new IllegalArgumentException("Marketplace version not found: " + versionId);
+ }
+
+ // Null-safe: a malformed payload without an itemId must surface the friendly
+ // IllegalArgumentException thrown by addPlanEntry(root) below, not an NPE here.
+ String rootItemId = optText(rootVersion, FIELD_ITEM_ID);
+ JsonNode related = rootVersion.get(FIELD_RELATED_ITEMS);
+
+ // The plan only ever touches the root and its (one-level-deep) related items, so we ask the
+ // DB whether just those item ids are already installed instead of loading every installed id
+ // for the tenant — a tenant may have thousands installed while a plan checks a handful.
+ Set candidateItemIds = new HashSet<>();
+ addCandidateItemId(candidateItemIds, rootItemId);
+ if (related != null && related.isArray()) {
+ for (JsonNode relatedNode : related) {
+ addCandidateItemId(candidateItemIds, relatedNode.asText());
+ }
+ }
+ Set alreadyInstalledItemIds = candidateItemIds.isEmpty()
+ ? Set.of()
+ : new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, candidateItemIds));
+
+ // LinkedHashMap preserves insertion order; we add the related items (deps) before the
+ // root so a single forward iteration yields the correct install sequence (deps first,
+ // root last). Related items are leaf dependencies — IoT Hub items are only ever one
+ // level deep, so there is no need to walk a related item's own related items.
+ LinkedHashMap entries = new LinkedHashMap<>();
+
+ if (related != null && related.isArray()) {
+ for (JsonNode relatedNode : related) {
+ String relatedItemId = relatedNode.asText();
+ if (relatedItemId == null || relatedItemId.isEmpty()
+ || relatedItemId.equals(rootItemId) || entries.containsKey(relatedItemId)) {
+ continue;
+ }
+ JsonNode relatedVersion;
+ try {
+ relatedVersion = iotHubRestClient.getPublishedVersionByItemId(relatedItemId);
+ } catch (Exception e) {
+ log.warn("Failed to fetch related item {}: {}", relatedItemId, e.getMessage());
+ entries.put(relatedItemId, missingEntry(relatedItemId, e.getMessage()));
+ continue;
+ }
+ if (relatedVersion == null) {
+ log.warn("Related IoT Hub item {} is missing or unpublished — recording in plan", relatedItemId);
+ entries.put(relatedItemId, missingEntry(relatedItemId, "Item not found or not published"));
+ continue;
+ }
+ addPlanEntry(relatedVersion, alreadyInstalledItemIds, entries, false);
+ }
+ }
+
+ addPlanEntry(rootVersion, alreadyInstalledItemIds, entries, true);
+
+ return new InstallPlan(versionId, new ArrayList<>(entries.values()));
+ }
+
+ /**
+ * Build an {@link InstallPlanEntry} for a single marketplace version and append it to the
+ * plan. No traversal of {@code relatedItems} happens here — related items are resolved one
+ * level deep by {@link #resolveInstallPlan}.
+ */
+ private void addPlanEntry(JsonNode versionInfo,
+ Set alreadyInstalledItemIds,
+ LinkedHashMap entries,
+ boolean root) {
+ String itemId = optText(versionInfo, FIELD_ITEM_ID);
+ String versionId = optText(versionInfo, FIELD_ID);
+ if (itemId == null || versionId == null) {
+ // The marketplace payload is missing its identifiers. The root must have them to be
+ // installable; a related item without them is recorded as missing so the rest of the
+ // plan can still proceed.
+ if (root) {
+ throw new IllegalArgumentException("Marketplace version is missing required '"
+ + FIELD_ITEM_ID + "'/'" + FIELD_ID + "' fields");
+ }
+ String key = itemId != null ? itemId : versionId;
+ log.warn("Related IoT Hub item is missing required identifiers — recording in plan: {}", versionInfo);
+ entries.putIfAbsent(key != null ? key : versionInfo.toString(),
+ missingEntry(itemId, "Item descriptor is incomplete"));
+ return;
+ }
+ if (entries.containsKey(itemId)) {
+ return;
+ }
+
+ InstallPlanEntry entry = new InstallPlanEntry();
+ entry.setItemId(itemId);
+ entry.setVersionId(versionId);
+ entry.setName(optText(versionInfo, FIELD_NAME));
+ entry.setType(optText(versionInfo, FIELD_TYPE));
+ entry.setVersion(optText(versionInfo, FIELD_VERSION));
+ entry.setRoot(root);
+ entry.setStatus(isAlreadyInstalled(entry, alreadyInstalledItemIds)
+ ? InstallPlanEntry.Status.ALREADY_INSTALLED
+ : InstallPlanEntry.Status.WILL_INSTALL);
+ entries.put(itemId, entry);
+ }
+
+ private static InstallPlanEntry missingEntry(String itemId, String errorMessage) {
+ InstallPlanEntry entry = new InstallPlanEntry();
+ entry.setItemId(itemId);
+ entry.setStatus(InstallPlanEntry.Status.MISSING);
+ entry.setErrorMessage(errorMessage);
+ return entry;
+ }
+
+ private static String optText(JsonNode node, String field) {
+ return node != null && node.hasNonNull(field) ? node.get(field).asText() : null;
+ }
+
+ @Override
+ public InstallPlanResult installPlan(SecurityUser user, InstallPlan plan, JsonNode data, HttpServletRequest request) {
+ TenantId tenantId = user.getTenantId();
+ if (plan == null || plan.getEntries() == null || plan.getEntries().isEmpty()) {
+ return new InstallPlanResult(false, false, "Install plan is empty", null, new ArrayList<>(), new ArrayList<>());
+ }
+
+ // The plan is resolved by a separate request, so by the time it is submitted an item may
+ // already have been installed (stale or replayed plan). Re-check the current state for only
+ // the items this plan would install, so we never install the same item twice.
+ Set candidateItemIds = new HashSet<>();
+ for (InstallPlanEntry entry : plan.getEntries()) {
+ if (entry.getStatus() == InstallPlanEntry.Status.WILL_INSTALL) {
+ addCandidateItemId(candidateItemIds, entry.getItemId());
+ }
+ }
+ // Mutable on purpose: as the cascade installs each item we add its id below, so a later
+ // duplicate entry for the same item in this plan is recognised as installed and skipped
+ // instead of installed twice. A client-supplied plan is not de-duplicated the way a freshly
+ // resolved one is, so the loop has to guard against duplicates itself.
+ Set alreadyInstalledItemIds = new HashSet<>();
+ if (!candidateItemIds.isEmpty()) {
+ alreadyInstalledItemIds.addAll(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, candidateItemIds));
+ }
+
+ InstallPlanResult result = new InstallPlanResult();
+ List resultEntries = new ArrayList<>();
+ List missingItemIds = new ArrayList<>();
+ List rollbackIds = new ArrayList<>();
+ IotHubInstalledItemDescriptor rootDescriptor = null;
+
+ for (InstallPlanEntry entry : plan.getEntries()) {
+ InstallPlanEntry resultEntry = cloneEntry(entry);
+ switch (entry.getStatus()) {
+ case MISSING -> {
+ missingItemIds.add(entry.getItemId());
+ resultEntries.add(resultEntry);
+ }
+ case ALREADY_INSTALLED -> resultEntries.add(resultEntry);
+ case WILL_INSTALL -> {
+ if (isAlreadyInstalled(entry, alreadyInstalledItemIds)) {
+ // Installed since the plan was resolved — skip rather than create a duplicate.
+ resultEntry.setStatus(InstallPlanEntry.Status.ALREADY_INSTALLED);
+ resultEntries.add(resultEntry);
+ break;
+ }
+ try {
+ // Only the root entry receives the user's install data (target profile entityId);
+ // transitive deps install with defaults.
+ JsonNode entryData = entry.isRoot() ? data : null;
+ IotHubInstalledItem installed = doInstallVersion(user, entry.getVersionId(), entryData, request);
+ rollbackIds.add(installed.getId());
+ // Record it as installed so a duplicate entry for the same item later in this
+ // plan is skipped rather than installed a second time.
+ addCandidateItemId(alreadyInstalledItemIds, entry.getItemId());
+ if (entry.isRoot()) {
+ rootDescriptor = installed.getDescriptor();
+ }
+ resultEntries.add(resultEntry);
+ } catch (Exception e) {
+ log.error("[{}] Cascade install failed at entry {} ({}): {}", tenantId,
+ entry.getName(), entry.getVersionId(), e.getMessage(), e);
+ resultEntry.setErrorMessage(e.getMessage());
+ resultEntries.add(resultEntry);
+ boolean rolledBack = rollbackInstalledItems(user, rollbackIds);
+ result.setSuccess(false);
+ result.setRolledBack(rolledBack);
+ result.setErrorMessage("Failed to install '" + entry.getName() + "': " + e.getMessage());
+ result.setEntries(resultEntries);
+ result.setMissingItemIds(missingItemIds);
+ return result;
+ }
+ }
+ }
+ }
+
+ result.setSuccess(true);
+ result.setRolledBack(false);
+ result.setRootDescriptor(rootDescriptor);
+ result.setEntries(resultEntries);
+ result.setMissingItemIds(missingItemIds);
+ return result;
+ }
+
+ /**
+ * Deletes the supplied installed items in reverse install order. Returns {@code true} only if
+ * every deletion succeeded — a {@code false} result means some entities were left behind and
+ * the rollback is partial.
+ */
+ private boolean rollbackInstalledItems(SecurityUser user, List installedIds) {
+ boolean fullyRolledBack = true;
+ for (int i = installedIds.size() - 1; i >= 0; i--) {
+ IotHubInstalledItemId id = installedIds.get(i);
+ try {
+ deleteInstalledItem(user, id);
+ } catch (Exception e) {
+ fullyRolledBack = false;
+ log.error("[{}] Failed to roll back installed item {}: {}", user.getTenantId(), id, e.getMessage(), e);
+ }
+ }
+ return fullyRolledBack;
+ }
+
+ private static void addCandidateItemId(Set candidates, String itemId) {
+ if (itemId == null || itemId.isEmpty()) {
+ return;
+ }
+ try {
+ candidates.add(UUID.fromString(itemId));
+ } catch (IllegalArgumentException ignored) {
+ // A non-UUID item id can never match an installed record — leave it out of the lookup.
+ }
+ }
+
+ private static boolean isAlreadyInstalled(InstallPlanEntry entry, Set alreadyInstalledItemIds) {
+ if (entry == null || entry.getItemId() == null) {
+ return false;
+ }
+ if (entry.isRoot() && (!"WIDGET".equals(entry.getType()) && !"SOLUTION_TEMPLATE".equals(entry.getType()))) {
+ return false;
+ }
+ try {
+ return alreadyInstalledItemIds.contains(UUID.fromString(entry.getItemId()));
+ } catch (IllegalArgumentException ex) {
+ return false;
+ }
+ }
+
+ private static InstallPlanEntry cloneEntry(InstallPlanEntry src) {
+ return new InstallPlanEntry(src.getItemId(), src.getVersionId(), src.getName(), src.getType(),
+ src.getVersion(), src.getStatus(), src.isRoot(), src.getErrorMessage());
+ }
+
+ private static Exception parseFailure(String action, String itemTypeName, Exception cause) {
+ return parseFailure(action, itemTypeName, SECTION_PACKAGE_DATA, cause);
+ }
+
+ private static Exception parseFailure(String action, String itemTypeName, String section, Exception cause) {
+ return new Exception("Unable to " + action + " this " + itemTypeName + " — the " + section + " could not be parsed. " +
+ "The package may be corrupted or in an unexpected format; please contact the creator to have it re-published.", cause);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java
new file mode 100644
index 0000000000..f91085cfd6
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class InstallItemVersionResult {
+
+ private boolean success;
+ private String errorMessage;
+ private IotHubInstalledItemDescriptor descriptor;
+
+ public static InstallItemVersionResult success(IotHubInstalledItemDescriptor descriptor) {
+ return new InstallItemVersionResult(true, null, descriptor);
+ }
+
+ public static InstallItemVersionResult error(String errorMessage) {
+ return new InstallItemVersionResult(false, errorMessage, null);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java
new file mode 100644
index 0000000000..12c268a6b4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class InstallPlan {
+
+ private String rootVersionId;
+ private List entries;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java
new file mode 100644
index 0000000000..c073ff8e0e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class InstallPlanEntry {
+
+ public enum Status {
+ WILL_INSTALL,
+ ALREADY_INSTALLED,
+ MISSING
+ }
+
+ private String itemId;
+ private String versionId;
+ private String name;
+ private String type;
+ private String version;
+ private Status status;
+ private boolean root;
+ private String errorMessage;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java
new file mode 100644
index 0000000000..2c3f49e071
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class InstallPlanResult {
+
+ private boolean success;
+ private boolean rolledBack;
+ private String errorMessage;
+ private IotHubInstalledItemDescriptor rootDescriptor;
+ private List entries = new ArrayList<>();
+ private List missingItemIds = new ArrayList<>();
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
new file mode 100644
index 0000000000..c67fef55a8
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
@@ -0,0 +1,128 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.RestTemplate;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Duration;
+
+@Component
+@TbCoreComponent
+@Slf4j
+public class IotHubRestClient {
+
+ @Value("${iot-hub.base-url:https://iot-hub.thingsboard.io}")
+ private String baseUrl;
+
+ @Value("${iot-hub.connect-timeout-sec:5}")
+ private int connectTimeoutSec;
+
+ @Value("${iot-hub.read-timeout-sec:10}")
+ private int readTimeoutSec;
+
+ @Value("${iot-hub.max-file-data-size-bytes:104857600}")
+ private long maxFileDataSizeBytes;
+
+ private RestTemplate restTemplate;
+
+ @PostConstruct
+ public void initRestClient() {
+ restTemplate = new RestTemplateBuilder()
+ .connectTimeout(Duration.ofSeconds(connectTimeoutSec))
+ .readTimeout(Duration.ofSeconds(readTimeoutSec))
+ .build();
+ }
+
+ public JsonNode getVersionInfo(String versionId) {
+ String url = baseUrl + "/api/versions/" + versionId;
+ log.debug("Fetching IoT Hub version info: {}", url);
+ return restTemplate.getForObject(url, JsonNode.class);
+ }
+
+ /**
+ * Returns the latest published version of an item, or {@code null} if the marketplace
+ * responds with 404 (item missing or never published). Other 4xx/5xx still propagate.
+ */
+ public JsonNode getPublishedVersionByItemId(String itemId) {
+ String url = baseUrl + "/api/items/" + itemId + "/published";
+ log.debug("Fetching IoT Hub published version for item: {}", url);
+ try {
+ return restTemplate.getForObject(url, JsonNode.class);
+ } catch (HttpClientErrorException e) {
+ if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
+ return null;
+ }
+ throw e;
+ }
+ }
+
+ public byte[] getVersionFileData(String versionId) {
+ String url = baseUrl + "/api/versions/" + versionId + "/fileData";
+ log.debug("Fetching IoT Hub version file data: {}", url);
+ // Stream the response so we can reject oversized payloads before
+ // they get loaded fully into memory:
+ // 1) refuse early if the server advertises a Content-Length
+ // larger than the configured cap;
+ // 2) refuse mid-stream once we have already buffered more than
+ // the cap (for chunked transfers without Content-Length).
+ final long limit = maxFileDataSizeBytes;
+ return restTemplate.execute(url, HttpMethod.GET, null, response -> {
+ long contentLength = response.getHeaders().getContentLength();
+ if (contentLength > limit) {
+ throw new IllegalStateException("IoT Hub file data size " + contentLength
+ + " bytes exceeds the configured limit of " + limit + " bytes");
+ }
+ int initialCapacity = contentLength > 0 && contentLength <= Integer.MAX_VALUE
+ ? (int) contentLength : 8192;
+ try (InputStream in = response.getBody();
+ ByteArrayOutputStream out = new ByteArrayOutputStream(initialCapacity)) {
+ byte[] buffer = new byte[8192];
+ long total = 0;
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ total += read;
+ if (total > limit) {
+ throw new IllegalStateException("IoT Hub file data stream exceeded the configured limit of "
+ + limit + " bytes");
+ }
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to read IoT Hub file data", e);
+ }
+ });
+ }
+
+ public void reportVersionInstalled(String versionId) {
+ String url = baseUrl + "/api/versions/" + versionId + "/install";
+ log.debug("Reporting IoT Hub version installed: {}", url);
+ restTemplate.postForObject(url, null, Void.class);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java
new file mode 100644
index 0000000000..fac0f94b10
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java
@@ -0,0 +1,37 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.servlet.http.HttpServletRequest;
+import org.thingsboard.server.common.data.id.IotHubInstalledItemId;
+import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor;
+import org.thingsboard.server.service.security.model.SecurityUser;
+
+public interface IotHubService {
+
+ InstallItemVersionResult installItemVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request);
+
+ UpdateItemVersionResult updateItemVersion(SecurityUser user, IotHubInstalledItemId installedItemId, String versionId, boolean force, HttpServletRequest request);
+
+ InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor);
+
+ void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId);
+
+ InstallPlan resolveInstallPlan(SecurityUser user, String versionId);
+
+ InstallPlanResult installPlan(SecurityUser user, InstallPlan plan, JsonNode data, HttpServletRequest request);
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java
new file mode 100644
index 0000000000..1ba7b0a7b5
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/UpdateItemVersionResult.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class UpdateItemVersionResult {
+
+ private boolean success;
+ private boolean entityModified;
+ private String errorMessage;
+ private IotHubInstalledItemDescriptor descriptor;
+
+ public static UpdateItemVersionResult success(IotHubInstalledItemDescriptor descriptor) {
+ return new UpdateItemVersionResult(true, false, null, descriptor);
+ }
+
+ public static UpdateItemVersionResult entityModified() {
+ return new UpdateItemVersionResult(false, true, null, null);
+ }
+
+ public static UpdateItemVersionResult error(String errorMessage) {
+ return new UpdateItemVersionResult(false, false, errorMessage, null);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
index bf53a8b401..ffb6af8319 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
@@ -464,10 +464,10 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService 0 ? RpcError.values()[proto.getError()] : null;
+ void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) {
+ RpcError error = RpcError.fromProtoErrorCode(proto.getError());
FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB())
- , proto.getResponse(), error);
+ , proto.hasResponse() ? proto.getResponse() : null, error);
tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response);
callback.onSuccess();
}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
index 74e0de9ea6..bb70a5f136 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
@@ -179,9 +179,9 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
callback.onSuccess();
} else if (nfMsg.hasFromDeviceRpcResponse()) {
TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse();
- RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null;
+ RpcError error = RpcError.fromProtoErrorCode(proto.getError());
FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB())
- , proto.getResponse(), error);
+ , proto.hasResponse() ? proto.getResponse() : null, error);
tbDeviceRpcService.processRpcResponseFromDevice(response);
callback.onSuccess();
} else if (nfMsg.getQueueUpdateMsgsCount() > 0) {
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java
new file mode 100644
index 0000000000..17a018bdfa
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java
@@ -0,0 +1,1715 @@
+/**
+ * Copyright © 2016-2026 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.solutions;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.gson.JsonParser;
+import jakarta.annotation.PreDestroy;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.common.util.ThingsBoardExecutors;
+import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.adaptor.JsonConverter;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.Dashboard;
+import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.DashboardInfo;
+import org.thingsboard.server.common.data.DeviceProfile;
+import org.thingsboard.server.common.data.HasName;
+import org.thingsboard.server.common.data.alarm.AlarmInfo;
+import org.thingsboard.server.common.data.alarm.AlarmQuery;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.iot_hub.SolutionTemplateInstalledItemDescriptor;
+import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery;
+import org.thingsboard.server.common.data.kv.DeleteTsKvQuery;
+import org.thingsboard.server.common.data.page.PageLink;
+import org.thingsboard.server.common.data.page.TimePageLink;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.asset.AssetProfile;
+import org.thingsboard.server.common.data.audit.ActionType;
+import org.thingsboard.server.common.data.rule.RuleChainType;
+import org.thingsboard.server.common.data.security.Authority;
+import org.thingsboard.server.common.data.security.UserCredentials;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.asset.Asset;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.debug.DebugSettings;
+import org.thingsboard.server.common.data.edge.Edge;
+import org.thingsboard.server.common.data.id.AlarmId;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.EntityIdFactory;
+import org.thingsboard.server.common.data.id.AssetProfileId;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.DashboardId;
+import org.thingsboard.server.common.data.id.EdgeId;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.UserId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.RuleChainId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationTypeGroup;
+import org.thingsboard.server.common.data.rule.RuleChain;
+import org.thingsboard.server.common.data.rule.RuleChainMetaData;
+import org.thingsboard.server.dao.alarm.AlarmService;
+import org.thingsboard.server.dao.asset.AssetProfileService;
+import org.thingsboard.server.dao.asset.AssetService;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
+import org.thingsboard.server.dao.attributes.AttributesService;
+import org.thingsboard.server.dao.customer.CustomerService;
+import org.thingsboard.server.dao.dashboard.DashboardService;
+import org.thingsboard.server.dao.device.DeviceConnectivityService;
+import org.thingsboard.server.dao.device.DockerComposeParams;
+import org.thingsboard.server.dao.device.DeviceCredentialsService;
+import org.thingsboard.server.dao.device.DeviceProfileService;
+import org.thingsboard.server.dao.device.DeviceService;
+import org.thingsboard.server.dao.edge.EdgeService;
+import org.thingsboard.server.dao.timeseries.TimeseriesService;
+import org.thingsboard.server.dao.user.UserService;
+import org.thingsboard.server.dao.rule.RuleChainService;
+import org.thingsboard.server.exception.EntitiesLimitExceededException;
+import org.thingsboard.server.exception.ThingsboardRuntimeException;
+import org.thingsboard.server.queue.discovery.PartitionService;
+import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
+import org.thingsboard.server.queue.provider.TbQueueProducerProvider;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+import org.thingsboard.server.service.action.EntityActionService;
+import org.thingsboard.server.service.entitiy.asset.TbAssetService;
+import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
+import org.thingsboard.server.service.entitiy.device.TbDeviceService;
+import org.thingsboard.server.service.entitiy.edge.TbEdgeService;
+import org.thingsboard.server.service.entitiy.entity.relation.TbEntityRelationService;
+import org.thingsboard.server.service.rule.TbRuleChainService;
+import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.system.SystemSecurityService;
+import org.thingsboard.server.service.solutions.data.CreatedAlarmRuleInfo;
+import org.thingsboard.server.service.solutions.data.CreatedCalculatedFieldInfo;
+import org.thingsboard.server.service.solutions.data.CreatedEntityInfo;
+import org.thingsboard.server.service.solutions.data.DashboardLinkInfo;
+import org.thingsboard.server.service.solutions.data.DeviceCredentialsInfo;
+import org.thingsboard.server.service.solutions.data.EdgeLinkInfo;
+import org.thingsboard.server.service.solutions.data.SolutionInstallContext;
+import org.thingsboard.server.service.solutions.data.UserCredentialsInfo;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.thingsboard.server.service.solutions.data.definition.AssetDefinition;
+import org.thingsboard.server.service.solutions.data.definition.AssetProfileDefinition;
+import org.thingsboard.server.service.solutions.data.definition.DeviceDefinition;
+import org.thingsboard.server.service.solutions.data.definition.CustomerDefinition;
+import org.thingsboard.server.service.solutions.data.definition.DashboardUserDetailsDefinition;
+import org.thingsboard.server.service.solutions.data.definition.CalculatedFieldDefinition;
+import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition;
+import org.thingsboard.server.service.solutions.data.definition.UserDefinition;
+import org.thingsboard.server.service.solutions.data.definition.DashboardDefinition;
+import org.thingsboard.server.service.solutions.data.definition.DeviceProfileDefinition;
+import org.thingsboard.server.service.solutions.data.definition.EdgeDefinition;
+import org.thingsboard.server.service.solutions.data.definition.RelationDefinition;
+import org.thingsboard.server.service.solutions.data.definition.ReferenceableEntityDefinition;
+import org.thingsboard.server.service.solutions.data.definition.TenantDefinition;
+import org.thingsboard.server.service.solutions.data.emulator.AssetEmulatorLauncher;
+import org.thingsboard.server.service.solutions.data.emulator.DeviceEmulatorLauncher;
+import org.thingsboard.server.service.solutions.data.names.RandomNameData;
+import org.thingsboard.server.service.solutions.data.names.RandomNameUtil;
+import org.thingsboard.server.service.solutions.data.solution.SolutionInstallResponse;
+import org.thingsboard.server.service.solutions.data.solution.TenantSolutionTemplateInstructions;
+import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.ExecutorService;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+@Service
+@TbCoreComponent
+@RequiredArgsConstructor
+@Slf4j
+public class DefaultSolutionService implements SolutionService {
+
+ @Value("${ui.solution_templates.docs_base_url:https://thingsboard.io/docs}")
+ private String docsBaseUrl;
+
+ @Value("${iot-hub.max-uncompressed-archive-bytes:209715200}")
+ private long maxUncompressedArchiveBytes;
+
+ @Value("${iot-hub.max-uncompressed-entry-bytes:52428800}")
+ private long maxUncompressedEntryBytes;
+
+ @Value("${iot-hub.max-archive-entry-count:10000}")
+ private int maxArchiveEntryCount;
+
+ @Value("${iot-hub.max-install-timeout-ms:60000}")
+ private long maxInstallTimeoutMs;
+
+ private final RuleChainService ruleChainService;
+ private final TbRuleChainService tbRuleChainService;
+ private final DeviceProfileService deviceProfileService;
+ private final DeviceService deviceService;
+ private final TbDeviceService tbDeviceService;
+ private final DeviceCredentialsService deviceCredentialsService;
+ private final AssetProfileService assetProfileService;
+ private final AssetService assetService;
+ private final TbAssetService tbAssetService;
+ private final CustomerService customerService;
+ private final UserService userService;
+ private final DashboardService dashboardService;
+ private final EdgeService edgeService;
+ private final TbEdgeService tbEdgeService;
+ private final TbEntityRelationService relationService;
+ private final AlarmService alarmService;
+ private final CalculatedFieldService calculatedFieldService;
+ private final TbCalculatedFieldService tbCalculatedFieldService;
+ private final AttributesService attributesService;
+ private final TimeseriesService tsService;
+ private final EntityActionService entityActionService;
+ private final SystemSecurityService systemSecurityService;
+ private final TbClusterService tbClusterService;
+ private final PartitionService partitionService;
+ private final TbQueueProducerProvider tbQueueProducerProvider;
+ private final TbServiceInfoProvider serviceInfoProvider;
+ private final TelemetrySubscriptionService tsSubService;
+ private final BCryptPasswordEncoder passwordEncoder;
+ private final DeviceConnectivityService deviceConnectivityService;
+
+ private final ExecutorService emulatorExecutor = ThingsBoardExecutors.newWorkStealingPool(10, "solution-emulators-executor");
+
+ @PreDestroy
+ private void destroy() {
+ emulatorExecutor.shutdownNow();
+ }
+
+ @Override
+ public SolutionInstallResponse installSolution(SecurityUser user, TenantId tenantId, byte[] zipData, HttpServletRequest request) throws Exception {
+ Path tempDir = Files.createTempDirectory("iot-hub-solution-");
+ try {
+ try {
+ extractZip(zipData, tempDir);
+ } catch (Throwable e) {
+ log.error("[{}] Failed to extract solution template zip", tenantId, e);
+ deleteDirectory(tempDir);
+ TenantSolutionTemplateInstructions instructions = new TenantSolutionTemplateInstructions();
+ instructions.setDetails(e.getMessage());
+ return new SolutionInstallResponse(instructions, false, List.of());
+ }
+
+ String solutionId = loadSolutionId(tempDir);
+ if (solutionId == null) {
+ throw new IllegalArgumentException("Solution template is missing solution.json or its 'title' field");
+ }
+
+ SolutionInstallResponse validateResult = validateSolution(tenantId, tempDir);
+ if (validateResult != null && !validateResult.isSuccess()) {
+ return validateResult;
+ }
+ return doInstallSolution(user, tenantId, solutionId, tempDir, request);
+ } finally {
+ deleteDirectory(tempDir);
+ }
+ }
+
+ @Override
+ public void deleteSolution(TenantId tenantId, SolutionTemplateInstalledItemDescriptor descriptor, SecurityUser user) throws ThingsboardException {
+ try {
+ if (descriptor.getCreatedEntityIds() != null && !descriptor.getCreatedEntityIds().isEmpty()) {
+ List entityIds = new ArrayList<>(descriptor.getCreatedEntityIds());
+ // Delete in the descending order of creation to avoid dependency issues.
+ Collections.reverse(entityIds);
+ for (EntityId entityId : entityIds) {
+ try {
+ deleteEntity(tenantId, entityId, user);
+ } catch (RuntimeException e) {
+ log.error("[{}] Failed to delete the entity: {}", tenantId, entityId, e);
+ }
+ }
+ }
+ List tsKeys = descriptor.getTenantTelemetryKeys();
+ if (tsKeys != null && !tsKeys.isEmpty()) {
+ List queries = new ArrayList<>(tsKeys.size());
+ for (String tsKey : tsKeys) {
+ queries.add(new BaseDeleteTsKvQuery(tsKey, 0, System.currentTimeMillis(), false));
+ }
+ tsService.remove(tenantId, tenantId, queries).get();
+ }
+ List attrKeys = descriptor.getTenantAttributeKeys();
+ if (attrKeys != null && !attrKeys.isEmpty()) {
+ attributesService.removeAll(tenantId, tenantId, AttributeScope.SERVER_SCOPE, attrKeys).get();
+ }
+ } catch (Exception e) {
+ log.error("[{}] Failed to delete the solution", tenantId, e);
+ throw new ThingsboardException(e, ThingsboardErrorCode.GENERAL);
+ }
+ }
+
+ private SolutionInstallResponse validateSolution(TenantId tenantId, Path tempDir) {
+ Map> alreadyExistingEntities = new HashMap<>();
+
+ //TODO: check other entities.
+
+ List ruleChains = loadListOfEntitiesIfFileExists(tempDir, "rule_chains.json", new TypeReference<>() {
+ });
+ if (!ruleChains.isEmpty()) {
+ for (ReferenceableEntityDefinition ruleChain : ruleChains) {
+ List savedRuleChains = ruleChainService.findTenantRuleChainsByType(tenantId, RuleChainType.CORE, new PageLink(1, 0, ruleChain.getName())).getData();
+ if (savedRuleChains != null && !savedRuleChains.isEmpty()) {
+ alreadyExistingEntities.computeIfAbsent(EntityType.RULE_CHAIN, key -> new ArrayList<>()).add(savedRuleChains.get(0));
+ }
+ }
+ }
+
+ List deviceProfiles = loadListOfEntitiesIfFileExists(tempDir, "device_profiles.json", new TypeReference<>() {
+ });
+ deviceProfiles.addAll(loadListOfEntitiesFromDirectory(tempDir, "device_profiles", DeviceProfileDefinition.class));
+ // Validate that entities with such name does not exist entities
+ if (!deviceProfiles.isEmpty()) {
+ for (DeviceProfile deviceProfile : deviceProfiles) {
+ DeviceProfile savedProfile = deviceProfileService.findDeviceProfileByName(tenantId, deviceProfile.getName());
+ if (savedProfile != null) {
+ alreadyExistingEntities.computeIfAbsent(EntityType.DEVICE_PROFILE, key -> new ArrayList<>()).add(savedProfile);
+ }
+ }
+ }
+
+ List assetProfiles = loadListOfEntitiesIfFileExists(tempDir, "asset_profiles.json", new TypeReference<>() {
+ });
+ assetProfiles.addAll(loadListOfEntitiesFromDirectory(tempDir, "asset_profiles", AssetProfileDefinition.class));
+ // Validate that entities with such name does not exist entities
+ if (!assetProfiles.isEmpty()) {
+ for (AssetProfile assetProfile : assetProfiles) {
+ AssetProfile savedProfile = assetProfileService.findAssetProfileByName(tenantId, assetProfile.getName());
+ if (savedProfile != null) {
+ alreadyExistingEntities.computeIfAbsent(EntityType.ASSET_PROFILE, key -> new ArrayList<>()).add(savedProfile);
+ }
+ }
+ }
+
+ List dashboards = loadListOfEntitiesIfFileExists(tempDir, "dashboards.json", new TypeReference<>() {
+ });
+ if (!dashboards.isEmpty()) {
+ for (DashboardDefinition dashboard : dashboards) {
+ List savedDashboards = dashboardService.findDashboardsByTenantId(tenantId, new PageLink(1, 0, dashboard.getName())).getData();
+ if (savedDashboards != null && !savedDashboards.isEmpty()) {
+ alreadyExistingEntities.computeIfAbsent(EntityType.DASHBOARD, key -> new ArrayList<>()).add(savedDashboards.get(0));
+ }
+ }
+ }
+ if (!alreadyExistingEntities.isEmpty()) {
+ SolutionInstallResponse solutionInstallResponse = new SolutionInstallResponse();
+ StringBuilder detailsBuilder = new StringBuilder();
+ detailsBuilder.append("## Validation failed").append(System.lineSeparator()).append(System.lineSeparator());
+ alreadyExistingEntities.forEach((type, list) -> detailsBuilder.append("The following **").append(getTypeLabel(type)).append("** entities already exist: ")
+ .append(list.stream().map(HasName::getName).map(name -> "'" + name + "'").collect(Collectors.joining(","))).append(";")
+ .append(System.lineSeparator()).append(System.lineSeparator()));
+ solutionInstallResponse.setSuccess(false);
+ solutionInstallResponse.setDetails(detailsBuilder.toString());
+ return solutionInstallResponse;
+ } else {
+ return null;
+ }
+ }
+
+ private SolutionInstallResponse doInstallSolution(User user, TenantId tenantId, String solutionId, Path tempDir, HttpServletRequest request) {
+ SolutionInstallContext ctx = new SolutionInstallContext(tenantId, solutionId, tempDir, user, new TenantSolutionTemplateInstructions());
+
+ try {
+
+ registerEmulatorsAndComputeOldestTelemetryTs(ctx);
+
+ provisionTenantDetails(ctx);
+
+ provisionRuleChains(ctx);
+
+ provisionDeviceProfiles(ctx);
+
+ provisionAssetProfiles(ctx);
+
+ List customers = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "customers.json", new TypeReference<>() {});
+
+ provisionCustomers(ctx, customers);
+
+ var assets = provisionAssets(ctx);
+
+ var devices = provisionDevices(ctx);
+
+ provisionDashboards(ctx);
+
+ provisionCustomerUsers(ctx, customers);
+
+ provisionRelations(ctx);
+
+ updateRuleChains(ctx);
+
+ provisionEdges(ctx);
+
+ provisionAlarmRules(ctx);
+
+ Set> telemetryLoading = launchEmulators(ctx, devices, assets);
+
+ waitForTelemetryCompletion(telemetryLoading);
+
+ provisionCalculatedFields(ctx);
+
+ ctx.getSolutionInstructions().setDetails(prepareInstructions(ctx, request));
+
+ List ruleChainDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {});
+ if (ruleChainDefs.stream().anyMatch(r -> StringUtils.isNotEmpty(r.getUpdate()))) {
+ long timeout = Math.min(loadInstallTimeoutMs(ctx.getTempDir()), maxInstallTimeoutMs);
+ if (timeout > 0) {
+ Thread.sleep(timeout);
+ }
+ finalUpdateRuleChains(ctx);
+ }
+
+ log.info("[{}] Solution template installed, created {} entities", tenantId, ctx.getCreatedEntitiesList().size());
+
+ return new SolutionInstallResponse(
+ new TenantSolutionTemplateInstructions(ctx.getSolutionInstructions()),
+ true,
+ ctx.getCreatedEntitiesList(),
+ loadTenantTelemetryKeys(ctx.getTempDir()),
+ loadTenantAttributeKeys(ctx.getTempDir())
+ );
+ } catch (Throwable e) {
+ log.error("[{}][{}] Failed to install solution template", tenantId, solutionId, e);
+ rollback(tenantId, solutionId, ctx, e);
+ if (e instanceof EntitiesLimitExceededException el) {
+ throw el;
+ }
+ return new SolutionInstallResponse(
+ new TenantSolutionTemplateInstructions(ctx.getSolutionInstructions()),
+ false,
+ ctx.getCreatedEntitiesList()
+ );
+ }
+ }
+
+ private void waitForTelemetryCompletion(Set> futures) throws InterruptedException {
+ CompletableFuture all = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
+ try {
+ all.get();
+ Thread.sleep(futures.size() * 100L);
+ } catch (ExecutionException e) {
+ throw new RuntimeException("Telemetry processing failed", e.getCause());
+ }
+ }
+
+ private void rollback(TenantId tenantId, String solutionId, SolutionInstallContext ctx, Throwable e) {
+ List createdEntities = new ArrayList<>(ctx.getCreatedEntitiesList());
+ Collections.reverse(createdEntities);
+ for (EntityId entityId : createdEntities) {
+ try {
+ deleteEntity(tenantId, entityId, ctx.getUser());
+ } catch (RuntimeException re) {
+ log.error("[{}][{}] Failed to delete the entity: {}", tenantId, solutionId, entityId, re);
+ }
+ }
+ ctx.getCreatedEntitiesList().clear();
+ ctx.getSolutionInstructions().setDetails(e.getMessage());
+ }
+
+ private String prepareInstructions(SolutionInstallContext ctx, HttpServletRequest request) {
+
+ Path instructionsFile = ctx.getTempDir().resolve("instructions.md");
+ if (!Files.exists(instructionsFile)) {
+ return null;
+ }
+ String template;
+ try {
+ template = Files.readString(instructionsFile);
+ } catch (IOException e) {
+ log.warn("[{}] Failed to read instructions.md", ctx.getTenantId(), e);
+ return null;
+ }
+
+ String baseUrl = systemSecurityService.getBaseUrl(ctx.getTenantId(), null, request);
+
+ // Inject edge instructions first, then run the full replacement logic on the combined string
+ if (template.contains("${edge_instructions}")) {
+ if (ctx.getCreatedEdges().isEmpty()) {
+ template = template.replace("${edge_instructions}", "");
+ } else {
+ Path edgeFile = ctx.getTempDir().resolve("edge_instructions.md");
+ String edgeTemplate = Files.exists(edgeFile) ? readFileContent(edgeFile) : "";
+ template = template.replace("${edge_instructions}", edgeTemplate);
+ }
+ }
+
+ template = template.replace("${DOCS_BASE_URL}", docsBaseUrl);
+ template = template.replace("${BASE_URL}", baseUrl);
+
+ TenantSolutionTemplateInstructions solutionInstructions = ctx.getSolutionInstructions();
+
+ if (solutionInstructions.getDashboardId() != null) {
+ template = template.replace("${MAIN_DASHBOARD_URL}",
+ getDashboardLink(solutionInstructions, solutionInstructions.getDashboardId(), false));
+ if (solutionInstructions.isMainDashboardPublic()) {
+ template = template.replace("${MAIN_DASHBOARD_PUBLIC_URL}",
+ getDashboardLink(solutionInstructions, solutionInstructions.getDashboardId(), true));
+ }
+ }
+
+ for (DashboardLinkInfo dashboardLinkInfo : ctx.getDashboardLinks()) {
+ template = template.replace("${" + dashboardLinkInfo.getName() + "DASHBOARD_URL}",
+ getDashboardLink(solutionInstructions, dashboardLinkInfo.getDashboardId(), false));
+ if (dashboardLinkInfo.isPublic()) {
+ template = template.replace("${" + dashboardLinkInfo.getName() + "DASHBOARD_PUBLIC_URL}",
+ getDashboardLink(solutionInstructions, dashboardLinkInfo.getDashboardId(), true));
+ }
+ }
+
+ if (template.contains("${GATEWAYS_URL}")) {
+ template = template.replace("${GATEWAYS_URL}", "/gateways");
+ }
+
+ // Device list and credentials
+ StringBuilder devList = new StringBuilder();
+ devList.append("| Device name | Access token | Owner |");
+ devList.append(System.lineSeparator());
+ devList.append("| :--- | :--- | :--- |");
+ devList.append(System.lineSeparator());
+
+ for (DeviceCredentialsInfo credentialsInfo : ctx.getCreatedDevices().values()) {
+ devList.append("|").append(credentialsInfo.getName())
+ .append("|").append(credentialsInfo.getCredentials().getCredentialsId()).append("{:copy-code}")
+ .append("|").append(credentialsInfo.getCustomerName() != null ? credentialsInfo.getCustomerName() : "Tenant");
+ devList.append(System.lineSeparator());
+
+ template = template.replace("${" + credentialsInfo.getName() + "ACCESS_TOKEN}", credentialsInfo.getCredentials().getCredentialsId());
+
+ if (credentialsInfo.isGateway()) {
+ template = template.replace("${DOCKER_CONFIG}",
+ prepareDockerComposeFile(ctx.getTenantId(), ctx.getSolutionId(), baseUrl, credentialsInfo.getCredentials().getDeviceId()));
+ }
+ }
+
+ template = template.replace("${device_list_and_credentials}", devList.toString());
+
+ // User list (without user group column)
+ StringBuilder userList = new StringBuilder();
+ userList.append("| Name | Login | Password | Customer name |");
+ userList.append(System.lineSeparator());
+ userList.append("| :--- | :--- | :--- | :--- |");
+ userList.append(System.lineSeparator());
+
+ for (UserCredentialsInfo credentialsInfo : ctx.getCreatedUsers().values()) {
+ userList.append("|").append(credentialsInfo.getName())
+ .append("|").append(credentialsInfo.getLogin()).append("{:copy-code}")
+ .append("|").append(credentialsInfo.getPassword()).append("{:copy-code}")
+ .append("|").append(credentialsInfo.getCustomerName() != null ? credentialsInfo.getCustomerName() : "");
+ userList.append(System.lineSeparator());
+ }
+
+ template = template.replace("${user_list}", userList.toString());
+
+ // Edge detail URLs
+ for (Map.Entry edgeLinkInfoEntry : ctx.getCreatedEdges().entrySet()) {
+ EdgeLinkInfo edgeLinkInfo = edgeLinkInfoEntry.getValue();
+ StringBuilder edgeDetailsUrl = new StringBuilder();
+ if (EntityType.CUSTOMER.equals(edgeLinkInfo.getOwnerId().getEntityType())) {
+ edgeDetailsUrl.append("/customers/").append(edgeLinkInfo.getOwnerId().getId());
+ edgeDetailsUrl.append("/edgeInstances/").append(edgeLinkInfo.getEdgeId().getId());
+ } else {
+ edgeDetailsUrl.append("/edgeManagement/instances/").append(edgeLinkInfo.getEdgeId().getId());
+ }
+ String edgeName = edgeLinkInfoEntry.getKey();
+ String edgeDetailsPlaceholder = "${" + edgeName + "EDGE_DETAILS_URL}";
+ template = template.replace(edgeDetailsPlaceholder, edgeDetailsUrl.toString());
+ }
+
+ template = replaceAlarmRules(ctx, template);
+ template = replaceCalculatedFields(ctx, template);
+ template = replaceCreatedEntities(ctx, template);
+
+ return template;
+ }
+
+ private static String replaceAlarmRules(SolutionInstallContext ctx, String template) {
+ StringBuilder alarmRules = new StringBuilder();
+
+ alarmRules.append("| Entity Profile Name | Alarm Type | Severities |").append(System.lineSeparator());
+ alarmRules.append("| :--- | :--- | :--- |").append(System.lineSeparator());
+
+ ctx.getCreatedAlarmRules().entrySet().stream()
+ .sorted(Map.Entry.comparingByValue(Comparator.comparing(CreatedAlarmRuleInfo::entityName, String.CASE_INSENSITIVE_ORDER)
+ .thenComparing(CreatedAlarmRuleInfo::alarmType, String.CASE_INSENSITIVE_ORDER)))
+ .forEach(entry -> {
+ UUID key = entry.getKey();
+ var alarmRuleInfo = entry.getValue();
+
+ String alarmType = alarmRuleInfo.alarmType();
+ String link = alarmRuleInfo.getCfPageLink(key);
+
+ String alarmTypeWithLink = "" + alarmType + "";
+
+ String profileName = alarmRuleInfo.entityId() != null ?
+ "" + alarmRuleInfo.entityName() + ""
+ : alarmRuleInfo.entityName();
+
+ alarmRules.append("|")
+ .append(profileName).append("|")
+ .append(alarmTypeWithLink).append("|")
+ .append(alarmRuleInfo.severities()).append("|")
+ .append(System.lineSeparator());
+ });
+
+ return template.replace("${alarm_rules}", alarmRules.toString());
+ }
+
+ private static String replaceCalculatedFields(SolutionInstallContext ctx, String template) {
+ StringBuilder calculatedFields = new StringBuilder();
+
+ calculatedFields.append("| Entity Profile Name | Field Name | Field Type |").append(System.lineSeparator());
+ calculatedFields.append("| :--- | :--- | :--- |").append(System.lineSeparator());
+
+ ctx.getCreatedCalculatedFields().entrySet().stream()
+ .sorted(Map.Entry.comparingByValue(
+ Comparator.comparing(CreatedCalculatedFieldInfo::entityName, String.CASE_INSENSITIVE_ORDER)
+ .thenComparing(CreatedCalculatedFieldInfo::name, String.CASE_INSENSITIVE_ORDER)
+ ))
+ .forEach(entry -> {
+ UUID key = entry.getKey();
+ var cfInfo = entry.getValue();
+
+ String cfTitle = cfInfo.name();
+ String link = cfInfo.getCfPageLink(key);
+
+ String cfTitleWithLink = "" + cfTitle + "";
+
+ String profileName = cfInfo.entityId() != null ?
+ "" + cfInfo.entityName() + ""
+ : cfInfo.entityName();
+
+ calculatedFields.append("|")
+ .append(profileName).append("|")
+ .append(cfTitleWithLink).append("|")
+ .append(cfInfo.type()).append("|")
+ .append(System.lineSeparator());
+ });
+
+ return template.replace("${calculated_fields}", calculatedFields.toString());
+ }
+
+ private static String replaceCreatedEntities(SolutionInstallContext ctx, String template) {
+ StringBuilder entityList = new StringBuilder();
+
+ entityList.append("| Name | Type | Owner |").append(System.lineSeparator());
+ entityList.append("| :--- | :--- | :--- |").append(System.lineSeparator());
+
+ for (Map.Entry entry : ctx.getCreatedEntities().entrySet()) {
+ UUID key = entry.getKey();
+ var entityInfo = entry.getValue();
+ String link = entityInfo.getEntityPageLink(key);
+ String entityName = entityInfo.getName();
+
+ String name = link != null ?
+ "" + entityName + ""
+ : entityName;
+
+ entityList.append("|")
+ .append(name).append("|")
+ .append(entityInfo.getType().getNormalName()).append("|")
+ .append(entityInfo.getOwner()).append("|")
+ .append(System.lineSeparator());
+ }
+ return template.replace("${all_entities}", entityList.toString());
+ }
+
+ private String getDashboardLink(TenantSolutionTemplateInstructions solutionInstructions, DashboardId dashboardId, boolean isPublic) {
+ if (isPublic && solutionInstructions.getPublicId() != null) {
+ return "/dashboard/" + dashboardId.getId() + "?publicId=" + solutionInstructions.getPublicId();
+ }
+ return "/dashboards/" + dashboardId.getId();
+ }
+
+ private String prepareDockerComposeFile(TenantId tenantId, String solutionId, String baseUrl, DeviceId deviceId) {
+ Device device = new Device(deviceId);
+ device.setTenantId(tenantId);
+ String containerName = "tb-gateway-" + solutionId.replace('_', '-');
+ DockerComposeParams params = new DockerComposeParams(false, containerName, false, true, false, false);
+ try (InputStream inputStream = deviceConnectivityService.createGatewayDockerComposeFile(baseUrl, device, params).getInputStream();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))
+ ) {
+ return reader.lines().collect(Collectors.joining("\n"));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to read or process the docker-compose.yml file.", e);
+ }
+ }
+
+ private void provisionRuleChains(SolutionInstallContext ctx) {
+ List ruleChainDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {});
+ for (ReferenceableEntityDefinition entityDef : ruleChainDefs) {
+ Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDef.getFile());
+ if (!Files.exists(ruleChainPath)) {
+ log.warn("[{}] Rule chain file not found: {}", ctx.getTenantId(), entityDef.getFile());
+ continue;
+ }
+ JsonNode ruleChainJson = replaceIds(ctx, JacksonUtil.toJsonNode(ruleChainPath));
+
+ RuleChain ruleChain = JacksonUtil.treeToValue(ruleChainJson.get("ruleChain"), RuleChain.class);
+ ruleChain.setId(null);
+ ruleChain.setTenantId(ctx.getTenantId());
+ String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata"));
+ RuleChainMetaData metadata = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class);
+
+ RuleChain savedRuleChain = ruleChainService.saveRuleChain(ruleChain);
+ metadata.setRuleChainId(savedRuleChain.getId());
+ metadata.setVersion(savedRuleChain.getVersion());
+ ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), metadata, tbRuleChainService::updateRuleNodeConfiguration);
+ if (ruleChain.isRoot()) {
+ ruleChainService.setRootRuleChain(ctx.getTenantId(), savedRuleChain.getId());
+ }
+
+ ctx.register(entityDef.getJsonId(), savedRuleChain);
+ log.debug("[{}] Rule chain provisioned: {}", ctx.getTenantId(), savedRuleChain.getName());
+ }
+ }
+
+ private void updateRuleChains(SolutionInstallContext ctx) {
+ List ruleChainDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {});
+ for (ReferenceableEntityDefinition entityDef : ruleChainDefs) {
+ Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDef.getFile());
+ if (!Files.exists(ruleChainPath)) {
+ continue;
+ }
+ String realId = ctx.getRealIds().get(entityDef.getJsonId());
+ if (realId == null) {
+ continue;
+ }
+ RuleChainId ruleChainId = new RuleChainId(UUID.fromString(realId));
+ RuleChain savedRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), ruleChainId);
+ if (savedRuleChain == null) {
+ continue;
+ }
+ JsonNode ruleChainJson = JacksonUtil.toJsonNode(ruleChainPath);
+ String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata"));
+ String oldMetadataStr = metadataStr;
+ for (var entry : ctx.getRealIds().entrySet()) {
+ metadataStr = metadataStr.replace(entry.getKey(), entry.getValue());
+ }
+ if (metadataStr.equals(oldMetadataStr)) {
+ continue;
+ }
+ RuleChainMetaData metadata = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class);
+ metadata.setRuleChainId(ruleChainId);
+ metadata.setVersion(savedRuleChain.getVersion());
+ ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), metadata, tbRuleChainService::updateRuleNodeConfiguration);
+ }
+ }
+
+ private void finalUpdateRuleChains(SolutionInstallContext ctx) {
+ List ruleChains = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "rule_chains.json", new TypeReference<>() {});
+ for (ReferenceableEntityDefinition entityDefinition : ruleChains) {
+ if (StringUtils.isEmpty(entityDefinition.getUpdate())) {
+ continue;
+ }
+ Path ruleChainPath = ctx.getTempDir().resolve("rule_chains").resolve(entityDefinition.getUpdate());
+ JsonNode ruleChainJson = JacksonUtil.toJsonNode(ruleChainPath);
+ RuleChain ruleChain = JacksonUtil.treeToValue(ruleChainJson.get("ruleChain"), RuleChain.class);
+ ruleChain.setTenantId(ctx.getTenantId());
+ String metadataStr = JacksonUtil.toString(ruleChainJson.get("metadata"));
+ for (var entry : ctx.getRealIds().entrySet()) {
+ metadataStr = metadataStr.replace(entry.getKey(), entry.getValue());
+ }
+ RuleChainMetaData ruleChainMetaData = JacksonUtil.treeToValue(JacksonUtil.toJsonNode(metadataStr), RuleChainMetaData.class);
+
+ String realRuleChainId = ctx.getRealIds().get(entityDefinition.getJsonId());
+ if (StringUtils.isEmpty(realRuleChainId)) {
+ continue;
+ }
+ RuleChainId ruleChainId = new RuleChainId(UUID.fromString(realRuleChainId));
+ RuleChain savedRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), ruleChainId);
+ ruleChainMetaData.setRuleChainId(savedRuleChain.getId());
+ ruleChainMetaData.setVersion(savedRuleChain.getVersion());
+ ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), ruleChainMetaData, tbRuleChainService::updateRuleNodeConfiguration);
+ }
+ }
+
+ private void provisionDeviceProfiles(SolutionInstallContext ctx) {
+ List deviceProfiles = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "device_profiles.json", new TypeReference<>() {});
+ deviceProfiles.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "device_profiles", DeviceProfileDefinition.class));
+ deviceProfiles.forEach(deviceProfile -> {
+ deviceProfile.setId(null);
+ deviceProfile.setCreatedTime(0L);
+ deviceProfile.setTenantId(ctx.getTenantId());
+ if (deviceProfile.getDefaultRuleChainId() != null) {
+ String newId = ctx.getRealIds().get(deviceProfile.getDefaultRuleChainId().getId().toString());
+ if (newId != null) {
+ deviceProfile.setDefaultRuleChainId(new RuleChainId(UUID.fromString(newId)));
+ } else {
+ log.error("[{}] Device profile: {} references non existing rule chain.", ctx.getTenantId(), deviceProfile.getName());
+ throw new RuntimeException("Device profile: " + deviceProfile.getName() + " references non existing rule chain.");
+ }
+ }
+ if (deviceProfile.getDefaultEdgeRuleChainId() != null) {
+ String newId = ctx.getRealIds().get(deviceProfile.getDefaultEdgeRuleChainId().getId().toString());
+ if (StringUtils.isEmpty(newId)) {
+ deviceProfile.setDefaultEdgeRuleChainId(null);
+ } else {
+ deviceProfile.setDefaultEdgeRuleChainId(new RuleChainId(UUID.fromString(newId)));
+ }
+ }
+ if (deviceProfile.getDefaultDashboardId() != null) {
+ String newId = ctx.getRealIds().get(deviceProfile.getDefaultDashboardId().getId().toString());
+ if (newId != null) {
+ deviceProfile.setDefaultDashboardId(new DashboardId(UUID.fromString(newId)));
+ }
+ }
+ });
+
+ deviceProfiles.forEach(deviceProfileDefinition -> {
+ DeviceProfile deviceProfile = new DeviceProfile(deviceProfileDefinition);
+ deviceProfile = deviceProfileService.saveDeviceProfile(deviceProfile);
+ ctx.register(deviceProfileDefinition, deviceProfile);
+ });
+ }
+
+ private void provisionAssetProfiles(SolutionInstallContext ctx) {
+ List assetProfiles = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "asset_profiles.json", new TypeReference<>() {});
+ assetProfiles.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "asset_profiles", AssetProfileDefinition.class));
+ assetProfiles.forEach(assetProfile -> {
+ assetProfile.setId(null);
+ assetProfile.setCreatedTime(0L);
+ assetProfile.setTenantId(ctx.getTenantId());
+ if (assetProfile.getDefaultRuleChainId() != null) {
+ String newId = ctx.getRealIds().get(assetProfile.getDefaultRuleChainId().getId().toString());
+ if (newId != null) {
+ assetProfile.setDefaultRuleChainId(new RuleChainId(UUID.fromString(newId)));
+ } else {
+ log.error("[{}] Asset profile: {} references non existing rule chain.", ctx.getTenantId(), assetProfile.getName());
+ throw new RuntimeException("Asset profile: " + assetProfile.getName() + " references non existing rule chain.");
+ }
+ }
+ if (assetProfile.getDefaultEdgeRuleChainId() != null) {
+ String newId = ctx.getRealIds().get(assetProfile.getDefaultEdgeRuleChainId().getId().toString());
+ if (StringUtils.isEmpty(newId)) {
+ assetProfile.setDefaultEdgeRuleChainId(null);
+ } else {
+ assetProfile.setDefaultEdgeRuleChainId(new RuleChainId(UUID.fromString(newId)));
+ }
+ }
+ });
+
+ assetProfiles.forEach(assetProfileDefinition -> {
+ AssetProfile assetProfile = new AssetProfile(assetProfileDefinition);
+ assetProfile = assetProfileService.saveAssetProfile(assetProfile);
+ ctx.register(assetProfileDefinition, assetProfile);
+ });
+ }
+
+ private CustomerId getPublicCustomerId(SolutionInstallContext ctx) {
+ CustomerId publicId = ctx.getSolutionInstructions().getPublicId();
+ if (publicId != null) {
+ return publicId;
+ }
+ Customer publicCustomer = customerService.findOrCreatePublicCustomer(ctx.getTenantId());
+ ctx.getSolutionInstructions().setPublicId(publicCustomer.getId());
+ return publicCustomer.getId();
+ }
+
+ private void provisionDashboards(SolutionInstallContext ctx) {
+ List dashboardDefs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "dashboards.json", new TypeReference<>() {});
+ for (DashboardDefinition entityDef : dashboardDefs) {
+ CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer());
+ Path dashboardPath = ctx.getTempDir().resolve("dashboards").resolve(entityDef.getFile());
+ if (!Files.exists(dashboardPath)) {
+ log.warn("[{}] Dashboard file not found: {}", ctx.getTenantId(), entityDef.getFile());
+ continue;
+ }
+ JsonNode dashboardJson = replaceIds(ctx, JacksonUtil.toJsonNode(dashboardPath));
+ Dashboard dashboardTemplate = JacksonUtil.treeToValue(dashboardJson, Dashboard.class);
+
+ Dashboard dashboard = new Dashboard();
+ dashboard.setTenantId(ctx.getTenantId());
+ dashboard.setTitle(entityDef.getName());
+ dashboard.setConfiguration(dashboardTemplate.getConfiguration());
+ dashboard.setImage(dashboardTemplate.getImage());
+ dashboard.setResources(dashboardTemplate.getResources());
+ if (dashboardJson.has("mobileHide") && dashboardJson.get("mobileHide").isBoolean()) {
+ dashboard.setMobileHide(dashboardJson.get("mobileHide").asBoolean());
+ }
+ if (dashboardJson.has("mobileOrder") && dashboardJson.get("mobileOrder").isInt()) {
+ dashboard.setMobileOrder(dashboardJson.get("mobileOrder").asInt());
+ }
+
+ dashboard = dashboardService.saveDashboard(dashboard);
+ if (customerId != null) {
+ dashboardService.assignDashboardToCustomer(ctx.getTenantId(), dashboard.getId(), customerId);
+ }
+ ctx.register(entityDef, dashboard);
+ ctx.putIdToMap(EntityType.DASHBOARD, entityDef.getName(), dashboard.getId());
+
+ if (entityDef.isMain()) {
+ ctx.getSolutionInstructions().setDashboardId(dashboard.getId());
+ ctx.getSolutionInstructions().setMainDashboardPublic(entityDef.isMakePublic());
+ }
+ ctx.getDashboardLinks().add(new DashboardLinkInfo(dashboard.getTitle(), dashboard.getId(), entityDef.isMakePublic()));
+
+ log.debug("[{}] Dashboard provisioned: {}", ctx.getTenantId(), dashboard.getTitle());
+ }
+ }
+
+ private void provisionRelations(SolutionInstallContext ctx) {
+ ctx.getRelationDefinitions().forEach((id, relations) -> {
+ for (RelationDefinition relationDef : relations) {
+ log.info("[{}] Saving relation: {}", id, relationDef);
+ EntityRelation entityRelation = new EntityRelation();
+ EntityId otherId = resolveRelatedEntityId(relationDef, ctx);
+ if (EntitySearchDirection.FROM.equals(relationDef.getDirection())) {
+ entityRelation.setFrom(otherId);
+ entityRelation.setTo(id);
+ } else {
+ entityRelation.setFrom(id);
+ entityRelation.setTo(otherId);
+ }
+ entityRelation.setTypeGroup(RelationTypeGroup.COMMON);
+ entityRelation.setType(relationDef.getType());
+ try {
+ relationService.save(ctx.getTenantId(), null, entityRelation, null);
+ } catch (Exception e) {
+ log.info("[{}] Failed to save relation: {}, cause: {}", id, relationDef, e.getMessage());
+ }
+ }
+ });
+ }
+
+ private EntityId resolveRelatedEntityId(RelationDefinition relationDef, SolutionInstallContext ctx) {
+ if (relationDef.getEntityType() == EntityType.TENANT) {
+ return ctx.getTenantId();
+ }
+ return ctx.getIdFromMap(relationDef.getEntityType(), relationDef.getEntityName());
+ }
+
+ private Map provisionDevices(SolutionInstallContext ctx) {
+ Map result = new HashMap<>();
+ Set deviceTypeSet = new HashSet<>();
+ List devices = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "devices.json", new TypeReference<>() {});
+ for (DeviceDefinition entityDef : devices) {
+ CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer());
+ Device entity = new Device();
+ entity.setTenantId(ctx.getTenantId());
+ entity.setName(entityDef.getName());
+ entity.setLabel(entityDef.getLabel());
+ ensureDeviceProfileExists(ctx, deviceTypeSet, entityDef);
+ entity.setType(entityDef.getType());
+ entity.setCustomerId(customerId);
+ entity.setAdditionalInfo(entityDef.getAdditionalInfo());
+ entity = deviceService.saveDevice(entity);
+ entityActionService.logEntityAction(ctx.getUser(), entity.getId(), entity, customerId, ActionType.ADDED, null);
+ ctx.register(entityDef, entity);
+ log.info("[{}] Saved device: {}", entity.getId(), entity);
+ DeviceId entityId = entity.getId();
+ ctx.putIdToMap(entityDef, entityId);
+ saveServerSideAttributes(ctx, entityId, entityDef.getAttributes());
+ saveSharedAttributes(ctx, entityId, entityDef.getSharedAttributes());
+ ctx.put(entityId, entityDef.getRelations());
+
+ DeviceCredentialsInfo deviceCredentialsInfo = new DeviceCredentialsInfo();
+ deviceCredentialsInfo.setName(entity.getName());
+ deviceCredentialsInfo.setType(entity.getType());
+ deviceCredentialsInfo.setCustomerName(entityDef.getCustomer());
+ deviceCredentialsInfo.setCredentials(deviceCredentialsService.findDeviceCredentialsByDeviceId(ctx.getTenantId(), entityId));
+ JsonNode additionalInfo = entity.getAdditionalInfo();
+ boolean isGateway = additionalInfo != null && additionalInfo.hasNonNull("gateway") && additionalInfo.get("gateway").asBoolean();
+ deviceCredentialsInfo.setGateway(isGateway);
+ ctx.addDeviceCredentials(deviceCredentialsInfo);
+
+ result.put(entity, entityDef);
+ }
+ return result;
+ }
+
+ private void ensureDeviceProfileExists(SolutionInstallContext ctx, Set deviceTypeSet, DeviceDefinition entityDef) {
+ if (!deviceTypeSet.contains(entityDef.getType())) {
+ DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByName(ctx.getTenantId(), entityDef.getType());
+ if (deviceProfile == null) {
+ DeviceProfile created = deviceProfileService.findOrCreateDeviceProfile(ctx.getTenantId(), entityDef.getType());
+ ctx.register(created.getId());
+ log.info("Saved device profile: {}", created.getId());
+ }
+ deviceTypeSet.add(entityDef.getType());
+ }
+ }
+
+ private void registerEmulatorsAndComputeOldestTelemetryTs(SolutionInstallContext ctx) {
+ List emulatorDefinitions = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "device_emulators.json", new TypeReference<>() {
+ });
+ Map deviceEmulators = emulatorDefinitions.stream().collect(Collectors.toMap(EmulatorDefinition::getName, Function.identity()));
+ emulatorDefinitions.stream().filter(ed -> StringUtils.isNotEmpty(ed.getExtendz()))
+ .forEach(ed -> {
+ EmulatorDefinition parent = deviceEmulators.get(ed.getExtendz());
+ if (parent != null) {
+ ed.enrich(parent);
+ }
+ });
+ Map assetEmulators = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "asset_emulators.json", new TypeReference>() {
+ }).stream().collect(Collectors.toMap(EmulatorDefinition::getName, Function.identity()));
+
+ ctx.setDeviceEmulators(deviceEmulators);
+ ctx.setAssetEmulators(assetEmulators);
+
+ long solutionInstallTs = ctx.getInstallTs();
+ long oldestDeviceEmulatorsTs = deviceEmulators.values().stream()
+ .mapToLong(value -> value.getOldestTs(solutionInstallTs))
+ .min().orElse(solutionInstallTs);
+ long oldestAssetEmulatorsTs = assetEmulators.values().stream()
+ .mapToLong(value -> value.getOldestTs(solutionInstallTs))
+ .min().orElse(solutionInstallTs);
+ long solutionOldestTs = Math.min(oldestDeviceEmulatorsTs, oldestAssetEmulatorsTs);
+
+ ctx.setOldestTelemetryTs(solutionOldestTs);
+ }
+
+ private Set> launchEmulators(SolutionInstallContext ctx, Map devicesMap, Map assets) throws Exception {
+ Set> results = new HashSet<>();
+
+ for (var entry : devicesMap.entrySet().stream().filter(e -> StringUtils.isNotBlank(e.getValue().getEmulator())).collect(Collectors.toSet())) {
+ results.add(DeviceEmulatorLauncher.builder()
+ .entity(entry.getKey())
+ .emulatorDefinition(ctx.getDeviceEmulators().get(entry.getValue().getEmulator()))
+ .oldTelemetryExecutor(emulatorExecutor)
+ .tbClusterService(tbClusterService)
+ .partitionService(partitionService)
+ .tbQueueProducerProvider(tbQueueProducerProvider)
+ .serviceInfoProvider(serviceInfoProvider)
+ .tsSubService(tsSubService)
+ .build().launch());
+ }
+
+ for (var entry : assets.entrySet().stream().filter(e -> StringUtils.isNotBlank(e.getValue().getEmulator())).collect(Collectors.toSet())) {
+ results.add(AssetEmulatorLauncher.builder()
+ .entity(entry.getKey())
+ .emulatorDefinition(ctx.getAssetEmulators().get(entry.getValue().getEmulator()))
+ .oldTelemetryExecutor(emulatorExecutor)
+ .tbClusterService(tbClusterService)
+ .partitionService(partitionService)
+ .tbQueueProducerProvider(tbQueueProducerProvider)
+ .serviceInfoProvider(serviceInfoProvider)
+ .tsSubService(tsSubService)
+ .build().launch());
+ }
+
+ return results;
+ }
+
+ private void provisionTenantDetails(SolutionInstallContext ctx) {
+ TenantDefinition tenant = loadEntityIfFileExists(ctx.getTempDir(), "tenant.json", TenantDefinition.class);
+ if (tenant != null) {
+ saveServerSideAttributes(ctx, ctx.getTenantId(), tenant.getAttributes());
+ ctx.put(ctx.getTenantId(), tenant.getRelations());
+ }
+ }
+
+ private Map provisionAssets(SolutionInstallContext ctx) {
+ Map result = new HashMap<>();
+ Set assetTypeSet = new HashSet<>();
+ List assets = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "assets.json", new TypeReference<>() {});
+ for (AssetDefinition entityDef : assets) {
+ Asset entity = new Asset();
+ entity.setTenantId(ctx.getTenantId());
+ entity.setName(entityDef.getName());
+ entity.setLabel(entityDef.getLabel());
+ entity.setType(entityDef.getType());
+ if (entityDef.isMakePublic()) {
+ entity.setCustomerId(getPublicCustomerId(ctx));
+ } else {
+ entity.setCustomerId(ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer()));
+ }
+ ensureAssetProfileExists(ctx, assetTypeSet, entityDef);
+ entity = assetService.saveAsset(entity);
+ ctx.register(entityDef, entity);
+ log.info("[{}] Saved asset: {}", entity.getId(), entity);
+ AssetId entityId = entity.getId();
+ ctx.putIdToMap(entityDef, entityId);
+ saveServerSideAttributes(ctx, entityId, entityDef.getAttributes());
+ ctx.put(entityId, entityDef.getRelations());
+ result.put(entity, entityDef);
+ }
+ return result;
+ }
+
+ private void ensureAssetProfileExists(SolutionInstallContext ctx, Set assetTypeSet, AssetDefinition entityDef) {
+ if (!assetTypeSet.contains(entityDef.getType())) {
+ AssetProfile assetProfile = assetProfileService.findAssetProfileByName(ctx.getTenantId(), entityDef.getType());
+ if (assetProfile == null) {
+ AssetProfile created = assetProfileService.findOrCreateAssetProfile(ctx.getTenantId(), entityDef.getType());
+ ctx.register(created.getId());
+ log.info("Saved asset profile: {}", created.getId());
+ }
+ assetTypeSet.add(entityDef.getType());
+ }
+ }
+
+ private void provisionCustomers(SolutionInstallContext ctx, List customers) {
+ for (CustomerDefinition entityDef : customers) {
+ entityDef.setRandomNameData(generateRandomName(ctx));
+ Customer customer = new Customer();
+ customer.setTenantId(ctx.getTenantId());
+ customer.setTitle(randomize(entityDef.getName(), entityDef.getRandomNameData()));
+ customer.setEmail(randomize(entityDef.getEmail(), entityDef.getRandomNameData()));
+ customer.setCountry(entityDef.getCountry());
+ customer.setCity(entityDef.getCity());
+ customer.setState(entityDef.getState());
+ customer.setZip(entityDef.getZip());
+ customer.setAddress(entityDef.getAddress());
+ customer = customerService.saveCustomer(customer);
+ log.info("[{}] Saved customer: {}", customer.getId(), customer);
+ ctx.register(entityDef, customer);
+ CustomerId entityId = customer.getId();
+ ctx.putIdToMap(entityDef, entityId);
+ saveServerSideAttributes(ctx, entityId, entityDef.getAttributes(), entityDef.getRandomNameData());
+ ctx.put(entityId, entityDef.getRelations());
+ entityDef.setName(customer.getName());
+ }
+ }
+
+ private void provisionCustomerUsers(SolutionInstallContext ctx, List customers) {
+ for (CustomerDefinition entityDef : customers) {
+ Customer customer = customerService.findCustomerByTenantIdAndTitle(ctx.getTenantId(), entityDef.getName()).get();
+ for (UserDefinition uDef : entityDef.getUsers()) {
+ String originalName = uDef.getName();
+ User user = createUser(ctx, customer, uDef, entityDef);
+
+ UserCredentials credentials = userService.findUserCredentialsByUserId(ctx.getTenantId(), user.getId());
+ credentials.setEnabled(true);
+ credentials.setActivateToken(null);
+ credentials.setPassword(passwordEncoder.encode(uDef.getPassword()));
+ userService.saveUserCredentials(ctx.getTenantId(), credentials);
+
+ DashboardUserDetailsDefinition dd = uDef.getDashboard();
+ if (dd != null) {
+ DashboardId dashboardId = ctx.getIdFromMap(EntityType.DASHBOARD, dd.getName());
+ ObjectNode additionalInfo = JacksonUtil.newObjectNode();
+ additionalInfo.put("defaultDashboardId", dashboardId.getId().toString());
+ additionalInfo.put("defaultDashboardFullscreen", dd.isFullScreen());
+ user.setAdditionalInfo(additionalInfo);
+ userService.saveUser(ctx.getTenantId(), user);
+ log.info("[{}] Added default dashboard for user {}", customer.getId(), user.getEmail());
+ }
+
+ UserCredentialsInfo credentialsInfo = new UserCredentialsInfo();
+ credentialsInfo.setName(user.getFirstName() + " " + user.getLastName());
+ credentialsInfo.setLogin(uDef.getName());
+ credentialsInfo.setPassword(uDef.getPassword());
+ credentialsInfo.setCustomerName(entityDef.getName());
+ ctx.addUserCredentials(credentialsInfo);
+ ctx.register(entityDef, user);
+ ctx.put(user.getId(), uDef.getRelations());
+ ctx.putIdToMap(EntityType.USER, originalName, user.getId());
+ ctx.putIdToMap(EntityType.USER, uDef.getName(), user.getId());
+ saveServerSideAttributes(ctx, user.getId(), uDef.getAttributes());
+ }
+ }
+ }
+
+ private User createUser(SolutionInstallContext ctx, Customer customer, UserDefinition uDef, CustomerDefinition cDef) {
+ int maxAttempts = 10;
+ int attempts = 0;
+ Exception finalE = null;
+ while (attempts < maxAttempts) {
+ try {
+ boolean lastAttempt = maxAttempts == (attempts + 1);
+ var randomName = lastAttempt ? RandomNameUtil.nextSuperRandom() : RandomNameUtil.next();
+ User user = new User();
+ if (!StringUtils.isEmpty(uDef.getFirstname())) {
+ user.setFirstName(randomize(uDef.getFirstname(), randomName, cDef.getRandomNameData()));
+ } else {
+ user.setFirstName(randomName.getFirstName());
+ }
+ if (!StringUtils.isEmpty(uDef.getLastname())) {
+ user.setLastName(randomize(uDef.getLastname(), randomName, cDef.getRandomNameData()));
+ } else {
+ user.setLastName(randomName.getLastName());
+ }
+ user.setAuthority(Authority.CUSTOMER_USER);
+ user.setEmail(randomize(uDef.getName(), randomName, cDef.getRandomNameData()));
+ user.setCustomerId(customer.getId());
+ user.setTenantId(ctx.getTenantId());
+ log.info("[{}] Saving user: {}", customer.getId(), user);
+ user = userService.saveUser(ctx.getTenantId(), user);
+ uDef.setName(user.getEmail());
+ return user;
+ } catch (Exception e) {
+ finalE = e;
+ attempts++;
+ }
+ }
+ throw new RuntimeException(finalE);
+ }
+
+ private void provisionEdges(SolutionInstallContext ctx) throws Exception {
+ List edges = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "edges.json", new TypeReference<>() {});
+ RuleChain edgeTemplateRootRuleChain = ruleChainService.getEdgeTemplateRootRuleChain(ctx.getTenantId());
+ for (EdgeDefinition entityDef : edges) {
+ CustomerId customerId = entityDef.isMakePublic() ? getPublicCustomerId(ctx) : ctx.getIdFromMap(EntityType.CUSTOMER, entityDef.getCustomer());
+ Edge entity = new Edge();
+ entity.setTenantId(ctx.getTenantId());
+ entity.setName(entityDef.getName());
+ entity.setLabel(entityDef.getLabel());
+ entity.setType(entityDef.getType());
+ entity.setCustomerId(customerId);
+ entity.setRoutingKey(UUID.randomUUID().toString());
+ entity.setSecret(StringUtils.randomAlphanumeric(20));
+ RuleChainId rootRuleChainId = edgeTemplateRootRuleChain.getId();
+ if (StringUtils.isNotBlank(entityDef.getRootRuleChainId())) {
+ String newId = ctx.getRealIds().get(entityDef.getRootRuleChainId());
+ if (newId != null) {
+ rootRuleChainId = new RuleChainId(UUID.fromString(newId));
+ } else {
+ log.error("[{}] Edge: {} references non existing rule chain.", ctx.getTenantId(), entity.getName());
+ throw new RuntimeException("Edge: " + entity.getName() + " references non existing rule chain.");
+ }
+ }
+ entity.setRootRuleChainId(rootRuleChainId);
+ RuleChain rootRuleChain = ruleChainService.findRuleChainById(ctx.getTenantId(), rootRuleChainId);
+ entity = tbEdgeService.save(entity, rootRuleChain, ctx.getUser());
+ ctx.register(entityDef, entity);
+ assignRuleChainsToEdge(ctx, entityDef.getRuleChainIds(), entity);
+ assignAssetsToEdge(ctx, entityDef.getAssetIds(), entity);
+ assignDevicesToEdge(ctx, entityDef.getDeviceIds(), entity);
+ assignDashboardsToEdge(ctx, entityDef.getDashboardIds(), entity);
+ log.info("[{}] Saved edge: {}", entity.getId(), entity);
+ EdgeId entityId = entity.getId();
+ ctx.putIdToMap(entityDef, entityId);
+ saveServerSideAttributes(ctx, entityId, entityDef.getAttributes());
+ ctx.put(entityId, entityDef.getRelations());
+ ctx.addEdgeLinkInfo(entity.getName(), new EdgeLinkInfo(entity.getId(), customerId != null ? customerId : ctx.getTenantId()));
+ }
+ }
+
+ private void assignRuleChainsToEdge(SolutionInstallContext ctx, List ruleChainIds, Edge entity) {
+ if (ruleChainIds == null || ruleChainIds.isEmpty()) {
+ return;
+ }
+ for (String strRuleChainId : ruleChainIds) {
+ String newId = ctx.getRealIds().get(strRuleChainId);
+ if (newId != null) {
+ RuleChainId ruleChainId = new RuleChainId(UUID.fromString(newId));
+ ruleChainService.assignRuleChainToEdge(ctx.getTenantId(), ruleChainId, entity.getId());
+ } else {
+ log.error("[{}] Edge: {} references non existing edge rule chain.", ctx.getTenantId(), entity.getName());
+ throw new RuntimeException("Edge: " + entity.getName() + " references non existing edge rule chain.");
+ }
+ }
+ }
+
+ private void assignAssetsToEdge(SolutionInstallContext ctx, List assetIds, Edge entity) {
+ if (assetIds == null || assetIds.isEmpty()) {
+ return;
+ }
+ for (String strAssetId : assetIds) {
+ String newId = ctx.getRealIds().get(strAssetId);
+ if (newId != null) {
+ AssetId assetId = new AssetId(UUID.fromString(newId));
+ assetService.assignAssetToEdge(ctx.getTenantId(), assetId, entity.getId());
+ } else {
+ log.error("[{}] Edge: {} references non existing asset.", ctx.getTenantId(), entity.getName());
+ throw new RuntimeException("Edge: " + entity.getName() + " references non existing asset.");
+ }
+ }
+ }
+
+ private void assignDevicesToEdge(SolutionInstallContext ctx, List deviceIds, Edge entity) {
+ if (deviceIds == null || deviceIds.isEmpty()) {
+ return;
+ }
+ for (String strDeviceId : deviceIds) {
+ String newId = ctx.getRealIds().get(strDeviceId);
+ if (newId != null) {
+ DeviceId deviceId = new DeviceId(UUID.fromString(newId));
+ deviceService.assignDeviceToEdge(ctx.getTenantId(), deviceId, entity.getId());
+ } else {
+ log.error("[{}] Edge: {} references non existing device.", ctx.getTenantId(), entity.getName());
+ throw new RuntimeException("Edge: " + entity.getName() + " references non existing device.");
+ }
+ }
+ }
+
+ private void assignDashboardsToEdge(SolutionInstallContext ctx, List dashboardIds, Edge entity) {
+ if (dashboardIds == null || dashboardIds.isEmpty()) {
+ return;
+ }
+ for (String strDashboardId : dashboardIds) {
+ String newId = ctx.getRealIds().get(strDashboardId);
+ if (newId != null) {
+ DashboardId dashboardId = new DashboardId(UUID.fromString(newId));
+ dashboardService.assignDashboardToEdge(ctx.getTenantId(), dashboardId, entity.getId());
+ } else {
+ log.error("[{}] Edge: {} references non existing dashboard.", ctx.getTenantId(), entity.getName());
+ throw new RuntimeException("Edge: " + entity.getName() + " references non existing dashboard.");
+ }
+ }
+ }
+
+ private void provisionAlarmRules(SolutionInstallContext ctx) {
+ List cfs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "alarm_rules.json", new TypeReference<>() {});
+ cfs.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "alarm_rules", CalculatedField.class));
+ cfs.forEach(cf -> ctx.register(createCalculatedField(cf, ctx)));
+ }
+
+ private void provisionCalculatedFields(SolutionInstallContext ctx) {
+ List cfs = loadListOfEntitiesIfFileExists(ctx.getTempDir(), "calculated_fields.json", new TypeReference<>() {
+ });
+ cfs.addAll(loadListOfEntitiesFromDirectory(ctx.getTempDir(), "calculated_fields", CalculatedFieldDefinition.class));
+
+ List createOnly = new ArrayList<>();
+ TreeMap> ordered = new TreeMap<>();
+
+ for (CalculatedFieldDefinition cf : cfs) {
+ if (cf.getReprocessingOrder() == null || cf.getReprocessingOrder() < 0) {
+ createOnly.add(cf);
+ } else {
+ ordered.computeIfAbsent(cf.getReprocessingOrder(), integer -> new ArrayList<>()).add(cf);
+ }
+ }
+
+ createOnly.forEach(cf -> ctx.register(createCalculatedField(cf, ctx)));
+
+ for (Map.Entry> entry : ordered.entrySet()) {
+ List cfDefs = entry.getValue();
+ for (CalculatedFieldDefinition cfDef : cfDefs) {
+ CalculatedField calculatedField = createCalculatedField(cfDef, ctx);
+ ctx.register(calculatedField);
+ }
+ }
+ }
+
+ private CalculatedField createCalculatedField(CalculatedField cf, SolutionInstallContext ctx) {
+ cf.setId(null);
+ cf.setCreatedTime(0L);
+ cf.setTenantId(ctx.getTenantId());
+ cf.setDebugSettings(new DebugSettings(true, System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(15)));
+
+ Map realIds = ctx.getRealIds();
+
+ EntityId entityId = cf.getEntityId();
+ if (entityId != null) {
+ String newEntityId = realIds.get(entityId.getId().toString());
+ if (newEntityId != null) {
+ cf.setEntityId(EntityIdFactory.getByTypeAndUuid(entityId.getEntityType(), newEntityId));
+ } else {
+ log.error("[{}] Calculated field: {} references non existing entity.", ctx.getTenantId(), cf.getName());
+ throw new RuntimeException("Calculated field: " + cf.getName() + " references non existing entity.");
+ }
+ }
+ if (cf.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argBasedCfg) {
+ argBasedCfg.getArguments().forEach((key, argument) -> {
+ EntityId refEntityId = argument.getRefEntityId();
+ if (refEntityId != null) {
+ if (refEntityId.getEntityType() == EntityType.TENANT) {
+ argument.setRefEntityId(ctx.getTenantId());
+ } else {
+ String newId = realIds.get(refEntityId.getId().toString());
+ if (newId != null) {
+ argument.setRefEntityId(EntityIdFactory.getByTypeAndUuid(refEntityId.getEntityType(), newId));
+ } else {
+ log.error("[{}][{}] Calculated field: {} references non existing entity.", ctx.getTenantId(), ctx.getSolutionId(), cf.getName());
+ throw new ThingsboardRuntimeException();
+ }
+ }
+ }
+ });
+ }
+
+ CalculatedField calculatedField = new CalculatedField(cf);
+ return calculatedFieldService.save(calculatedField);
+ }
+
+ private RandomNameData generateRandomName(SolutionInstallContext ctx) {
+ int i = 0;
+ while (i < 10) {
+ var randomName = RandomNameUtil.next();
+ var user = userService.findUserByEmail(ctx.getTenantId(), randomName.getEmail());
+ if (user == null) {
+ return randomName;
+ } else {
+ i++;
+ }
+ }
+ String firstName = StringUtils.randomAlphanumeric(5);
+ String lastName = StringUtils.randomAlphanumeric(5);
+ return new RandomNameData(firstName, lastName, firstName + "." + lastName + "@thingsboard.io");
+ }
+
+ private String randomize(String src, RandomNameData name) {
+ return randomize(src, name, null);
+ }
+
+ private String randomize(String src, RandomNameData name, RandomNameData customer) {
+ if (src == null) {
+ return null;
+ } else {
+ String result = src
+ .replace("$randomFirstName", name.getFirstName())
+ .replace("$randomLastName", name.getLastName())
+ .replace("$randomEmail", name.getEmail());
+ if (customer != null) {
+ result = result
+ .replace("$customerFirstName", customer.getFirstName())
+ .replace("$customerLastName", customer.getLastName())
+ .replace("$customerEmail", customer.getEmail());
+ }
+ return result.replace("$random", StringUtils.randomAlphanumeric(10).toLowerCase());
+ }
+ }
+
+ private void saveServerSideAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes) {
+ saveServerSideAttributes(ctx, entityId, attributes, null);
+ }
+
+ private void saveServerSideAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes, RandomNameData randomNameData) {
+ saveAttributes(ctx, entityId, attributes, randomNameData, AttributeScope.SERVER_SCOPE);
+ }
+
+ private void saveSharedAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes) {
+ if (!EntityType.DEVICE.equals(entityId.getEntityType())) {
+ throw new IllegalArgumentException(entityId.getEntityType() + " cannot have shared attributes.");
+ }
+ saveAttributes(ctx, entityId, attributes, null, AttributeScope.SHARED_SCOPE);
+ }
+
+ private void saveAttributes(SolutionInstallContext ctx, EntityId entityId, JsonNode attributes, RandomNameData randomNameData, AttributeScope attributeScope) {
+ if (attributes != null && !attributes.isNull() && !attributes.isEmpty()) {
+ attributes = prepareAttributes(attributes);
+ log.info("[{}] Saving attributes: {}", entityId, attributes);
+ if (randomNameData != null) {
+ attributes = JacksonUtil.toJsonNode(randomize(JacksonUtil.toString(attributes), randomNameData, null));
+ }
+ attributesService.save(ctx.getTenantId(), entityId, attributeScope,
+ new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString(JacksonUtil.toString(attributes)), ctx.getOldestTelemetryTs())));
+ }
+ }
+
+ private JsonNode prepareAttributes(JsonNode attributes) {
+ ObjectNode attributesObj = (ObjectNode) attributes;
+ attributes.fields().forEachRemaining(entry -> {
+ JsonNode value = entry.getValue();
+ if (value.isTextual() && isTimeExpression(value.asText())) {
+ value = JacksonUtil.toJsonNode(parseTimeExpression(value.asText()));
+ }
+ attributesObj.set(entry.getKey(), value);
+ });
+ return attributesObj;
+ }
+
+ private boolean isTimeExpression(String text) {
+ return Pattern.matches("\\$\\{currentTime(?:([+-])(\\d+)([mwdh]|min))?}", text);
+ }
+
+ private String parseTimeExpression(String timeExpression) {
+ Matcher matcher = Pattern.compile("\\$\\{currentTime(?:([+-])(\\d+)([mwdh]|min))?}").matcher(timeExpression);
+
+ if (!matcher.matches()) {
+ return timeExpression;
+ }
+
+ String operator = matcher.group(1);
+ String amountStr = matcher.group(2);
+ String unit = matcher.group(3);
+
+ ZonedDateTime now = ZonedDateTime.now();
+
+ if (operator != null && amountStr != null && unit != null) {
+ int amount = Integer.parseInt(amountStr);
+ now = switch (unit) {
+ case "m" -> operator.equals("+") ? now.plusMonths(amount) : now.minusMonths(amount);
+ case "w" -> operator.equals("+") ? now.plusWeeks(amount) : now.minusWeeks(amount);
+ case "d" -> operator.equals("+") ? now.plusDays(amount) : now.minusDays(amount);
+ case "h" -> operator.equals("+") ? now.plusHours(amount) : now.minusHours(amount);
+ case "min" -> operator.equals("+") ? now.plusMinutes(amount) : now.minusMinutes(amount);
+ default -> now;
+ };
+ }
+
+ return String.valueOf(now.toInstant().toEpochMilli());
+ }
+
+ private String getTypeLabel(EntityType type) {
+ return type.name().toLowerCase().replace('_', ' ');
+ }
+
+ private T loadEntityIfFileExists(Path tempDir, String fileName, Class clazz) {
+ Path filePath = tempDir.resolve("entities").resolve(fileName);
+ if (Files.exists(filePath)) {
+ return JacksonUtil.readValue(filePath.toFile(), clazz);
+ } else {
+ return null;
+ }
+ }
+
+ private List loadListOfEntitiesIfFileExists(Path tempDir, String fileName, TypeReference> typeReference) {
+ Path filePath = tempDir.resolve("entities").resolve(fileName);
+ if (Files.exists(filePath)) {
+ try {
+ return JacksonUtil.readValue(filePath.toFile(), typeReference);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Invalid json file " + fileName + " data structure", e);
+ }
+ } else {
+ return new ArrayList<>();
+ }
+ }
+
+ private List loadListOfEntitiesFromDirectory(Path tempDir, String dirName, Class clazz) {
+ Path dirPath = tempDir.resolve(dirName);
+ if (Files.exists(dirPath) && Files.isDirectory(dirPath)) {
+ List result = new ArrayList<>();
+ try {
+ for (Path filePath : Files.list(dirPath).collect(Collectors.toList())) {
+ try {
+ result.add(JacksonUtil.readValue(filePath.toFile(), clazz));
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Invalid json file " + filePath.getFileName() + " data structure", e);
+ }
+ }
+ } catch (IOException e) {
+ log.warn("Failed to read directory: {}", dirName, e);
+ throw new RuntimeException(e);
+ }
+ return result;
+ } else {
+ return new ArrayList<>();
+ }
+ }
+
+ private String loadSolutionId(Path tempDir) {
+ Path solutionJson = tempDir.resolve("solution.json");
+ if (Files.exists(solutionJson)) {
+ JsonNode node = JacksonUtil.toJsonNode(solutionJson);
+ if (node != null && node.has("title")) {
+ String title = node.get("title").asText("");
+ return title.trim().toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", "");
+ }
+ }
+ return null;
+ }
+
+ private long loadInstallTimeoutMs(Path tempDir) {
+ Path solutionJson = tempDir.resolve("solution.json");
+ if (Files.exists(solutionJson)) {
+ JsonNode node = JacksonUtil.toJsonNode(solutionJson);
+ if (node != null && node.has("installTimeoutMs")) {
+ return node.get("installTimeoutMs").asLong(0L);
+ }
+ }
+ return 0L;
+ }
+
+ private List loadTenantTelemetryKeys(Path tempDir) {
+ Path solutionJson = tempDir.resolve("solution.json");
+ if (Files.exists(solutionJson)) {
+ JsonNode node = JacksonUtil.toJsonNode(solutionJson);
+ if (node != null && node.has("tenantTelemetryKeys")) {
+ return JacksonUtil.convertValue(node.get("tenantTelemetryKeys"), new TypeReference<>() {});
+ }
+ }
+ return Collections.emptyList();
+ }
+
+ private List loadTenantAttributeKeys(Path tempDir) {
+ Path solutionJson = tempDir.resolve("solution.json");
+ if (Files.exists(solutionJson)) {
+ JsonNode node = JacksonUtil.toJsonNode(solutionJson);
+ if (node != null && node.has("tenantAttributeKeys")) {
+ return JacksonUtil.convertValue(node.get("tenantAttributeKeys"), new TypeReference<>() {});
+ }
+ }
+ return Collections.emptyList();
+ }
+
+ private static final int EXTRACT_BUFFER_SIZE = 8 * 1024;
+
+ private void extractZip(byte[] zipData, Path destDir) throws IOException {
+ long totalBytes = 0;
+ int entryCount = 0;
+ byte[] buf = new byte[EXTRACT_BUFFER_SIZE];
+ try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipData))) {
+ ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null) {
+ if (++entryCount > maxArchiveEntryCount) {
+ throw new IOException("Solution template archive exceeds max entry count: " + maxArchiveEntryCount);
+ }
+ Path entryPath = destDir.resolve(entry.getName()).normalize();
+ if (!entryPath.startsWith(destDir)) {
+ throw new IOException("ZIP entry outside of target directory: " + entry.getName());
+ }
+ if (entry.isDirectory()) {
+ Files.createDirectories(entryPath);
+ } else {
+ Files.createDirectories(entryPath.getParent());
+ try (OutputStream out = Files.newOutputStream(entryPath)) {
+ long entryBytes = 0;
+ int n;
+ while ((n = zis.read(buf)) > 0) {
+ entryBytes += n;
+ totalBytes += n;
+ if (entryBytes > maxUncompressedEntryBytes) {
+ throw new IOException("Solution template entry exceeds max uncompressed size: " + entry.getName());
+ }
+ if (totalBytes > maxUncompressedArchiveBytes) {
+ throw new IOException("Solution template archive exceeds max uncompressed size: " + maxUncompressedArchiveBytes + " bytes");
+ }
+ out.write(buf, 0, n);
+ }
+ }
+ }
+ zis.closeEntry();
+ }
+ }
+ }
+
+ private static void deleteDirectory(Path dir) {
+ try {
+ Files.walkFileTree(dir, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+ Files.delete(file);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path d, IOException exc) throws IOException {
+ Files.delete(d);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ } catch (IOException e) {
+ log.warn("Failed to clean up temp directory: {}", dir, e);
+ }
+ }
+
+ private void deleteEntity(TenantId tenantId, EntityId entityId, User user) {
+ try {
+ List alarmIds = alarmService.findAlarms(tenantId, new AlarmQuery(entityId, new TimePageLink(Integer.MAX_VALUE), null, null, null, false))
+ .getData().stream().map(AlarmInfo::getId).collect(Collectors.toList());
+ Set typesToRemove = new HashSet<>();
+ alarmIds.forEach(alarmId -> {
+ var result = alarmService.delAlarm(tenantId, alarmId, false);
+ if (result.isSuccessful()) {
+ typesToRemove.add(result.getAlarm().getType());
+ }
+ });
+ alarmService.delAlarmTypes(tenantId, typesToRemove);
+ } catch (Exception e) {
+ log.error("[{}] Failed to delete alarms for entity", entityId.getId(), e);
+ }
+ switch (entityId.getEntityType()) {
+ case CALCULATED_FIELD:
+ CalculatedField cf = calculatedFieldService.findById(tenantId, new CalculatedFieldId(entityId.getId()));
+ if (cf != null) {
+ tbCalculatedFieldService.delete(cf, user);
+ }
+ break;
+ case RULE_CHAIN:
+ ruleChainService.deleteRuleChainById(tenantId, new RuleChainId(entityId.getId()));
+ break;
+ case DEVICE:
+ Device device = deviceService.findDeviceById(tenantId, new DeviceId(entityId.getId()));
+ if (device != null) {
+ tbDeviceService.delete(device, user);
+ }
+ break;
+ case DEVICE_PROFILE:
+ deviceProfileService.deleteDeviceProfile(tenantId, new DeviceProfileId(entityId.getId()));
+ break;
+ case ASSET:
+ Asset asset = assetService.findAssetById(tenantId, new AssetId(entityId.getId()));
+ if (asset != null) {
+ tbAssetService.delete(asset, user);
+ }
+ break;
+ case ASSET_PROFILE:
+ assetProfileService.deleteAssetProfile(tenantId, new AssetProfileId(entityId.getId()));
+ break;
+ case CUSTOMER:
+ customerService.deleteCustomer(tenantId, new CustomerId(entityId.getId()));
+ break;
+ case USER:
+ User userToDelete = userService.findUserById(tenantId, new UserId(entityId.getId()));
+ if (userToDelete != null) {
+ userService.deleteUser(tenantId, userToDelete);
+ }
+ break;
+ case EDGE:
+ Edge edge = edgeService.findEdgeById(tenantId, new EdgeId(entityId.getId()));
+ if (edge != null) {
+ tbEdgeService.delete(edge, user);
+ }
+ break;
+ case DASHBOARD:
+ dashboardService.deleteDashboard(tenantId, new DashboardId(entityId.getId()));
+ break;
+ default:
+ log.warn("[{}] Unsupported entity type for deletion: {}", tenantId, entityId.getEntityType());
+ }
+ }
+
+ private JsonNode replaceIds(SolutionInstallContext ctx, JsonNode json) {
+ String jsonStr = JacksonUtil.toString(json);
+ for (var e : ctx.getRealIds().entrySet()) {
+ jsonStr = jsonStr.replace(e.getKey(), e.getValue());
+ }
+ return JacksonUtil.toJsonNode(jsonStr);
+ }
+
+ private String readFileContent(Path path) {
+ try {
+ return Files.readString(path);
+ } catch (IOException e) {
+ log.warn("Failed to read file: {}", path, e);
+ return "";
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/SolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/SolutionService.java
new file mode 100644
index 0000000000..58c47d1dc2
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/SolutionService.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2026 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.solutions;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.iot_hub.SolutionTemplateInstalledItemDescriptor;
+import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.solutions.data.solution.SolutionInstallResponse;
+
+import java.util.List;
+
+public interface SolutionService {
+
+ SolutionInstallResponse installSolution(SecurityUser user, TenantId tenantId, byte[] zipData, HttpServletRequest request) throws Exception;
+
+ void deleteSolution(TenantId tenantId, SolutionTemplateInstalledItemDescriptor descriptor, SecurityUser user) throws ThingsboardException;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedAlarmRuleInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedAlarmRuleInfo.java
new file mode 100644
index 0000000000..e03b28588f
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedAlarmRuleInfo.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import org.thingsboard.server.common.data.alarm.AlarmSeverity;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.id.EntityId;
+
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+public record CreatedAlarmRuleInfo(EntityId entityId, String entityName, String alarmType,
+ String severities) implements HasAppliedToEntity {
+
+ public static CreatedAlarmRuleInfo from(EntityId entityId, String entityName, CalculatedField calculatedField) {
+ if (calculatedField.getType() != CalculatedFieldType.ALARM) {
+ throw new UnsupportedOperationException("Only alarm calculated fields are supported");
+ }
+ String severities = ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration())
+ .getCreateRules().keySet().stream()
+ .map(AlarmSeverity::getDisplayName)
+ .sorted()
+ .collect(Collectors.joining(", "));
+ return new CreatedAlarmRuleInfo(entityId, entityName, calculatedField.getName(), severities);
+ }
+
+ @Override
+ public String getCfPageLink(UUID cfId) {
+ return "/alarms/alarm-rules/" + cfId;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.java
new file mode 100644
index 0000000000..7b19fbc0cf
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedCalculatedFieldInfo.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.id.EntityId;
+
+import java.util.UUID;
+
+public record CreatedCalculatedFieldInfo(EntityId entityId, String entityName, String type,
+ String name) implements HasAppliedToEntity {
+
+ public static CreatedCalculatedFieldInfo from(EntityId entityId, String entityName, CalculatedField calculatedField) {
+ return new CreatedCalculatedFieldInfo(entityId, entityName, calculatedField.getType().getDisplayName(), calculatedField.getName());
+ }
+
+ @Override
+ public String getCfPageLink(UUID cfId) {
+ return "/calculatedFields/" + cfId;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java
new file mode 100644
index 0000000000..53105a6e31
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedEntityInfo.java
@@ -0,0 +1,49 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.EntityType;
+
+import java.util.UUID;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class CreatedEntityInfo {
+
+ private String name;
+ private EntityType type;
+ private String owner;
+
+ public String getEntityPageLink(UUID id) {
+ return switch (type) {
+ case CUSTOMER -> "/customers/" + id;
+ case USER -> "/users/" + id;
+ case ASSET -> "/entities/assets/" + id;
+ case DEVICE -> "/entities/devices/" + id;
+ case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + id;
+ case ASSET_PROFILE -> "/profiles/assetProfiles/" + id;
+ case DASHBOARD -> "/dashboards/" + id;
+ case RULE_CHAIN -> "/ruleChains/" + id;
+ case EDGE -> "/edgeManagement/instances/" + id;
+ default -> null;
+ };
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.java
new file mode 100644
index 0000000000..9a785242b2
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/CreatedRuleChainInfo.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.rule.RuleChainType;
+
+import java.util.UUID;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CreatedRuleChainInfo extends CreatedEntityInfo {
+
+ private RuleChainType ruleChainType;
+
+ public CreatedRuleChainInfo(String name, RuleChainType ruleChainType, String owner) {
+ super(name, EntityType.RULE_CHAIN, owner);
+ this.ruleChainType = ruleChainType;
+ }
+
+ @Override
+ public String getEntityPageLink(UUID id) {
+ return ruleChainType == RuleChainType.EDGE ? "/edgeManagement/ruleChains/" + id : "/ruleChains/" + id;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java
new file mode 100644
index 0000000000..63eb0e3757
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/DashboardLinkInfo.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.DashboardId;
+
+@Data
+public class DashboardLinkInfo {
+
+ private final String name;
+ private final DashboardId dashboardId;
+ private final boolean isPublic;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java
new file mode 100644
index 0000000000..e93104729c
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/DeviceCredentialsInfo.java
@@ -0,0 +1,30 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.security.DeviceCredentials;
+
+@Data
+public class DeviceCredentialsInfo {
+
+ String name;
+ String type;
+ DeviceCredentials credentials;
+ String customerName;
+ boolean gateway;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java
new file mode 100644
index 0000000000..e5f057636d
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/EdgeLinkInfo.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EdgeId;
+import org.thingsboard.server.common.data.id.EntityId;
+
+@Data
+public class EdgeLinkInfo {
+
+ private final EdgeId edgeId;
+ private final EntityId ownerId;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/HasAppliedToEntity.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/HasAppliedToEntity.java
new file mode 100644
index 0000000000..9b029bfa78
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/HasAppliedToEntity.java
@@ -0,0 +1,43 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import org.thingsboard.server.common.data.id.EntityId;
+
+import java.util.UUID;
+
+public interface HasAppliedToEntity {
+
+ EntityId entityId();
+
+ String getCfPageLink(UUID cfId);
+
+ default String getEntityPageLink() {
+ EntityId id = entityId();
+ if (id == null) {
+ return null;
+ }
+ String idStr = id.getId().toString();
+ return switch (id.getEntityType()) {
+ case DEVICE_PROFILE -> "/profiles/deviceProfiles/" + idStr;
+ case ASSET_PROFILE -> "/profiles/assetProfiles/" + idStr;
+ case DEVICE -> "/entities/devices/" + idStr;
+ case ASSET -> "/entities/assets/" + idStr;
+ default -> null;
+ };
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java
new file mode 100644
index 0000000000..4985ca1685
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java
@@ -0,0 +1,210 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.Dashboard;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.DeviceProfile;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.asset.Asset;
+import org.thingsboard.server.common.data.asset.AssetProfile;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.edge.Edge;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.rule.RuleChain;
+import org.thingsboard.server.service.solutions.data.definition.AssetDefinition;
+import org.thingsboard.server.service.solutions.data.definition.AssetProfileDefinition;
+import org.thingsboard.server.service.solutions.data.definition.CustomerDefinition;
+import org.thingsboard.server.service.solutions.data.definition.DashboardDefinition;
+import org.thingsboard.server.service.solutions.data.definition.DeviceDefinition;
+import org.thingsboard.server.service.solutions.data.definition.EdgeDefinition;
+import org.thingsboard.server.service.solutions.data.definition.DeviceProfileDefinition;
+import org.thingsboard.server.service.solutions.data.definition.EntityDefinition;
+import org.thingsboard.server.service.solutions.data.definition.EntitySearchKey;
+import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition;
+import org.thingsboard.server.service.solutions.data.definition.RelationDefinition;
+import org.thingsboard.server.service.solutions.data.solution.TenantSolutionTemplateInstructions;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@Data
+public class SolutionInstallContext {
+
+ private final TenantId tenantId;
+ private final Path tempDir;
+ private final User user;
+ private final TenantSolutionTemplateInstructions solutionInstructions;
+ private final List createdEntitiesList = new ArrayList<>();
+ private final Map realIds = new HashMap<>();
+ private final Map entityIdMap = new HashMap<>();
+ private final Map> relationDefinitions = new LinkedHashMap<>();
+ private final List dashboardLinks = new ArrayList<>();
+
+ // For instructions
+ private final Map createdDevices = new LinkedHashMap<>();
+ private final Map createdUsers = new LinkedHashMap<>();
+ private final Map createdEdges = new LinkedHashMap<>();
+ private final Map createdEntities = new LinkedHashMap<>();
+ private final Map createdAlarmRules = new LinkedHashMap<>();
+ private final Map createdCalculatedFields = new LinkedHashMap<>();
+ private final String solutionId;
+ private final long installTs;
+ private long oldestTelemetryTs;
+ private Map deviceEmulators;
+ private Map assetEmulators;
+
+ public SolutionInstallContext(TenantId tenantId, String solutionId, Path tempDir, User user, TenantSolutionTemplateInstructions solutionInstructions) {
+ this.tenantId = tenantId;
+ this.solutionId = solutionId;
+ this.tempDir = tempDir;
+ this.user = user;
+ this.solutionInstructions = solutionInstructions;
+ this.installTs = System.currentTimeMillis();
+ put(new EntitySearchKey(tenantId, EntityType.TENANT, null), tenantId);
+ }
+
+ public void registerReferenceOnly(String referenceId, EntityId entityId) {
+ if (!StringUtils.isEmpty(referenceId)) {
+ realIds.put(referenceId, entityId.getId().toString());
+ }
+ }
+
+ public void register(String referenceId, EntityId entityId) {
+ registerReferenceOnly(referenceId, entityId);
+ register(entityId);
+ }
+
+ public void register(EntityId entityId) {
+ createdEntitiesList.add(entityId);
+ }
+
+ public void register(String referenceId, RuleChain ruleChain) {
+ register(referenceId, ruleChain.getId());
+ createdEntities.put(ruleChain.getUuidId(), new CreatedRuleChainInfo(ruleChain.getName(), ruleChain.getType(), "Tenant"));
+ }
+
+ public void register(DeviceProfileDefinition definition, DeviceProfile deviceProfile) {
+ register(definition.getJsonId(), deviceProfile.getId());
+ createdEntities.put(deviceProfile.getUuidId(), new CreatedEntityInfo(deviceProfile.getName(), EntityType.DEVICE_PROFILE, "Tenant"));
+ }
+
+ public void register(AssetProfileDefinition definition, AssetProfile assetProfile) {
+ register(definition.getJsonId(), assetProfile.getId());
+ createdEntities.put(assetProfile.getUuidId(), new CreatedEntityInfo(assetProfile.getName(), EntityType.ASSET_PROFILE, "Tenant"));
+ }
+
+ public void register(AssetDefinition definition, Asset asset) {
+ register(definition.getJsonId(), asset.getId());
+ createdEntities.put(asset.getUuidId(), new CreatedEntityInfo(asset.getName(), EntityType.ASSET,
+ StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer()));
+ }
+
+ public void register(DeviceDefinition definition, Device device) {
+ register(definition.getJsonId(), device.getId());
+ createdEntities.put(device.getUuidId(), new CreatedEntityInfo(device.getName(), EntityType.DEVICE,
+ StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer()));
+ }
+
+ public void register(CustomerDefinition definition, Customer customer) {
+ register(definition.getJsonId(), customer.getId());
+ createdEntities.put(customer.getUuidId(), new CreatedEntityInfo(customer.getName(), EntityType.CUSTOMER, "Tenant"));
+ }
+
+ public void register(CustomerDefinition customerDef, User user) {
+ register((String) null, user.getId());
+ createdEntities.put(user.getUuidId(), new CreatedEntityInfo(user.getName(), EntityType.USER,
+ StringUtils.isEmpty(customerDef.getName()) ? "Tenant" : customerDef.getName()));
+ }
+
+ public void register(EdgeDefinition definition, Edge edge) {
+ register(definition.getJsonId(), edge.getId());
+ createdEntities.put(edge.getUuidId(), new CreatedEntityInfo(edge.getName(), EntityType.EDGE,
+ StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer()));
+ }
+
+ public void register(DashboardDefinition definition, Dashboard dashboard) {
+ register(definition.getJsonId(), dashboard.getId());
+ createdEntities.put(dashboard.getUuidId(), new CreatedEntityInfo(dashboard.getTitle(), EntityType.DASHBOARD,
+ StringUtils.isEmpty(definition.getCustomer()) ? "Tenant" : definition.getCustomer()));
+ }
+
+ public void register(CalculatedField calculatedField) {
+ register(calculatedField.getId());
+ EntityId entityId = calculatedField.getEntityId();
+ CreatedEntityInfo entityInfo = createdEntities.get(entityId.getId());
+ boolean alarmRule = calculatedField.getType() == CalculatedFieldType.ALARM;
+ if (entityInfo == null) {
+ String target = alarmRule ? "Alarm rule" : "Calculated field";
+ throw new IllegalStateException("Failed to register " + target + " with name: " +
+ calculatedField.getName() + " for non-existing entity with id: " + entityId);
+ }
+ if (alarmRule) {
+ createdAlarmRules.put(calculatedField.getUuidId(), CreatedAlarmRuleInfo.from(entityId, entityInfo.getName(), calculatedField));
+ return;
+ }
+ createdCalculatedFields.put(calculatedField.getUuidId(), CreatedCalculatedFieldInfo.from(entityId, entityInfo.getName(), calculatedField));
+ }
+
+ public void putIdToMap(EntityDefinition entityDefinition, EntityId entityId) {
+ putIdToMap(entityDefinition.getEntityType(), entityDefinition.getName(), entityId);
+ }
+
+ public void putIdToMap(EntityType entityType, String entityName, EntityId entityId) {
+ putIdToMap(tenantId, entityType, entityName, entityId);
+ }
+
+ public void putIdToMap(EntityId ownerId, EntityType entityType, String entityName, EntityId entityId) {
+ put(new EntitySearchKey(ownerId, entityType, entityName), entityId);
+ }
+
+ public void put(EntitySearchKey entitySearchKey, EntityId entityId) {
+ entityIdMap.put(entitySearchKey, entityId);
+ }
+
+ @SuppressWarnings("unchecked")
+ public T getIdFromMap(EntityType entityType, String entityName) {
+ return (T) entityIdMap.get(new EntitySearchKey(tenantId, entityType, entityName));
+ }
+
+ public void put(EntityId entityId, List relations) {
+ relationDefinitions.put(entityId, relations);
+ }
+
+ public void addDeviceCredentials(DeviceCredentialsInfo deviceCredentialsInfo) {
+ createdDevices.put(deviceCredentialsInfo.getName(), deviceCredentialsInfo);
+ }
+
+ public void addUserCredentials(UserCredentialsInfo userCredentialsInfo) {
+ createdUsers.put(userCredentialsInfo.getName(), userCredentialsInfo);
+ }
+
+ public void addEdgeLinkInfo(String edgeName, EdgeLinkInfo edgeLinkInfo) {
+ createdEdges.put(edgeName, edgeLinkInfo);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java
new file mode 100644
index 0000000000..d35900c5b4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/UserCredentialsInfo.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data;
+
+import lombok.Data;
+
+@Data
+public class UserCredentialsInfo {
+
+ String name;
+ String login;
+ String password;
+ String customerName;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.java
new file mode 100644
index 0000000000..2554dbafb0
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetDefinition.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.EntityType;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@AllArgsConstructor
+public class AssetDefinition extends CustomerEntityDefinition {
+
+ private String type;
+ private String label;
+ private String emulator;
+
+ @Override
+ public EntityType getEntityType() {
+ return EntityType.ASSET;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java
new file mode 100644
index 0000000000..9f95294fa7
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/AssetProfileDefinition.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.thingsboard.server.common.data.asset.AssetProfile;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AssetProfileDefinition extends AssetProfile {
+
+ private String jsonId;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java
new file mode 100644
index 0000000000..84ec880380
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/BaseEntityDefinition.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Collections;
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public abstract class BaseEntityDefinition implements EntityDefinition {
+
+ @JsonProperty("name")
+ private String name;
+ @JsonProperty("attributes")
+ private JsonNode attributes;
+ @JsonProperty("sharedAttributes")
+ private JsonNode sharedAttributes;
+ @JsonProperty("relations")
+ private List relations = Collections.emptyList();
+ @JsonProperty("jsonId")
+ private String jsonId;
+
+ public void setRelations(List relations) {
+ if (relations != null) {
+ this.relations = relations;
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java
new file mode 100644
index 0000000000..1dcc7a6bbd
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CalculatedFieldDefinition.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CalculatedFieldDefinition extends CalculatedField {
+
+ private Integer reprocessingOrder;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java
new file mode 100644
index 0000000000..3cc40c658c
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerDefinition.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.service.solutions.data.names.RandomNameData;
+
+import java.util.Collections;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+public class CustomerDefinition extends BaseEntityDefinition {
+
+ private String email;
+ private String country;
+ private String city;
+ private String state;
+ private String zip;
+ private String address;
+ private List users = Collections.emptyList();
+
+ @JsonIgnore
+ private RandomNameData randomNameData;
+
+ @Override
+ public EntityType getEntityType() {
+ return EntityType.CUSTOMER;
+ }
+
+ public void setUsers(List users) {
+ if (users != null) {
+ this.users = users;
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java
new file mode 100644
index 0000000000..d2da2f266c
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/CustomerEntityDefinition.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public abstract class CustomerEntityDefinition extends BaseEntityDefinition {
+
+ private String customer;
+ private boolean makePublic;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java
new file mode 100644
index 0000000000..a5a9e9c22b
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardDefinition.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.EntityType;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@AllArgsConstructor
+public class DashboardDefinition extends CustomerEntityDefinition {
+
+ private String file;
+ private boolean main;
+
+ @Override
+ public EntityType getEntityType() {
+ return EntityType.DASHBOARD;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java
new file mode 100644
index 0000000000..2ec4701829
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DashboardUserDetailsDefinition.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.Data;
+
+@Data
+public class DashboardUserDetailsDefinition {
+
+ private String name;
+ private boolean fullScreen;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java
new file mode 100644
index 0000000000..81239c46dd
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceDefinition.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.EntityType;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeviceDefinition extends CustomerEntityDefinition {
+
+ private String type;
+ private String label;
+ private String emulator;
+ private JsonNode additionalInfo;
+
+ @Override
+ public EntityType getEntityType() {
+ return EntityType.DEVICE;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java
new file mode 100644
index 0000000000..3dee5c179a
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/DeviceProfileDefinition.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.thingsboard.server.common.data.DeviceProfile;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class DeviceProfileDefinition extends DeviceProfile {
+
+ private String jsonId;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java
new file mode 100644
index 0000000000..5e8e7aa203
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EdgeDefinition.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.EntityType;
+
+import java.util.Collections;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@AllArgsConstructor
+public class EdgeDefinition extends CustomerEntityDefinition {
+
+ private String type;
+ private String label;
+ private String rootRuleChainId;
+ private List ruleChainIds = Collections.emptyList();
+ private List deviceIds = Collections.emptyList();
+ private List assetIds = Collections.emptyList();
+ private List dashboardIds = Collections.emptyList();
+
+ @Override
+ public EntityType getEntityType() {
+ return EntityType.EDGE;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.java
new file mode 100644
index 0000000000..37c726e091
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EmulatorDefinition.java
@@ -0,0 +1,64 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Data
+public class EmulatorDefinition {
+ private String name;
+ private String extendz;
+ private String clazz;
+ private int publishPeriodInDays;
+ private int publishFrequencyInSeconds;
+ private int publishPauseInMillis;
+ private long activityPeriodInMillis;
+ private List telemetryProfiles = Collections.emptyList();
+
+ public void enrich(EmulatorDefinition parent) {
+ if (StringUtils.isEmpty(clazz)) {
+ clazz = parent.getClazz();
+ }
+ if (publishPeriodInDays == 0) {
+ publishPeriodInDays = parent.getPublishPeriodInDays();
+ }
+ if (publishFrequencyInSeconds == 0) {
+ publishFrequencyInSeconds = parent.getPublishFrequencyInSeconds();
+ }
+ if (publishPauseInMillis == 0) {
+ publishPauseInMillis = parent.getPublishPauseInMillis();
+ }
+ if (activityPeriodInMillis == 0L) {
+ activityPeriodInMillis = parent.getActivityPeriodInMillis();
+ }
+ var profilesMap = telemetryProfiles.stream().collect(Collectors.toMap(TelemetryProfile::getKey, Function.identity()));
+ parent.getTelemetryProfiles().forEach(tp -> profilesMap.putIfAbsent(tp.getKey(), tp));
+ telemetryProfiles = new ArrayList<>(profilesMap.values());
+ }
+
+ public long getOldestTs(long startTs) {
+ return startTs - TimeUnit.DAYS.toMillis(publishPeriodInDays) - publishFrequencyInSeconds;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java
new file mode 100644
index 0000000000..c76d183bda
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntityDefinition.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import org.thingsboard.server.common.data.EntityType;
+
+public interface EntityDefinition {
+
+ String getName();
+
+ EntityType getEntityType();
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.java
new file mode 100644
index 0000000000..f880e909b0
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/EntitySearchKey.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.id.EntityId;
+
+@Data
+@AllArgsConstructor
+public class EntitySearchKey {
+
+ private final EntityId ownerId;
+ private final EntityType entityType;
+ private final String entityName;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java
new file mode 100644
index 0000000000..13dabb08cc
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/ReferenceableEntityDefinition.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ReferenceableEntityDefinition {
+
+ private String name;
+ private String file;
+ private String update;
+ private String jsonId;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java
new file mode 100644
index 0000000000..670d3f1ab4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/RelationDefinition.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class RelationDefinition {
+
+ private EntityType entityType;
+ private String entityName;
+ private EntitySearchDirection direction;
+ private String type;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.java
new file mode 100644
index 0000000000..259744a2f0
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TelemetryProfile.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import org.thingsboard.server.service.solutions.data.values.ValueStrategyDefinition;
+
+@Data
+@AllArgsConstructor
+public class TelemetryProfile {
+
+ private String key;
+ private ValueStrategyDefinition valueStrategy;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.java
new file mode 100644
index 0000000000..957547cff6
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/TenantDefinition.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.EntityType;
+
+@Data
+public class TenantDefinition extends BaseEntityDefinition {
+
+ @Override
+ public EntityType getEntityType() {
+ return EntityType.TENANT;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.java
new file mode 100644
index 0000000000..1815cc6e57
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/definition/UserDefinition.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.definition;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.EntityType;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserDefinition extends BaseEntityDefinition {
+
+ private String firstname;
+ private String lastname;
+ private String password;
+ private DashboardUserDetailsDefinition dashboard;
+
+ @Override
+ public EntityType getEntityType() {
+ return EntityType.USER;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java
new file mode 100644
index 0000000000..abed74af3e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AbstractEmulatorLauncher.java
@@ -0,0 +1,195 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.emulator;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.util.Pair;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.data.HasName;
+import org.thingsboard.server.common.data.HasTenantId;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.HasId;
+import org.thingsboard.server.common.data.msg.TbMsgType;
+import org.thingsboard.server.common.msg.TbMsg;
+import org.thingsboard.server.common.msg.TbMsgDataType;
+import org.thingsboard.server.common.msg.TbMsgMetaData;
+import org.thingsboard.server.queue.TbQueueCallback;
+import org.thingsboard.server.queue.TbQueueMsgMetadata;
+import org.thingsboard.server.queue.discovery.PartitionService;
+import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
+import org.thingsboard.server.queue.provider.TbQueueProducerProvider;
+import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition;
+import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Data
+public abstract class AbstractEmulatorLauncher & HasTenantId & HasName> {
+
+ protected static final TbQueueCallback EMPTY_CALLBACK = new TbQueueCallback() {
+ @Override
+ public void onSuccess(TbQueueMsgMetadata metadata) {
+
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+
+ }
+ };
+ protected final T entity;
+ protected final EmulatorDefinition emulatorDefinition;
+ protected final ExecutorService oldTelemetryExecutor;
+ protected final TbClusterService tbClusterService;
+ protected final PartitionService partitionService;
+ protected final TbQueueProducerProvider tbQueueProducerProvider;
+ protected final TbServiceInfoProvider serviceInfoProvider;
+ protected final TelemetrySubscriptionService tsSubService;
+ protected final long publishFrequency;
+
+ private final Emulator emulator;
+ private ScheduledFuture> scheduledFuture;
+
+ public AbstractEmulatorLauncher(T entity,
+ EmulatorDefinition emulatorDefinition,
+ ExecutorService oldTelemetryExecutor,
+ TbClusterService tbClusterService,
+ PartitionService partitionService,
+ TbQueueProducerProvider tbQueueProducerProvider,
+ TbServiceInfoProvider serviceInfoProvider,
+ TelemetrySubscriptionService tsSubService) throws Exception {
+ this.entity = entity;
+ this.emulatorDefinition = emulatorDefinition;
+ this.oldTelemetryExecutor = oldTelemetryExecutor;
+ this.tbClusterService = tbClusterService;
+ this.partitionService = partitionService;
+ this.tbQueueProducerProvider = tbQueueProducerProvider;
+ this.serviceInfoProvider = serviceInfoProvider;
+ this.tsSubService = tsSubService;
+ this.publishFrequency = TimeUnit.SECONDS.toMillis(Math.max(emulatorDefinition.getPublishFrequencyInSeconds(), 1));
+ if (StringUtils.isEmpty(emulatorDefinition.getClazz())) {
+ emulator = new BasicEmulator();
+ } else {
+ emulator = (Emulator) Class.forName(emulatorDefinition.getClazz()).getDeclaredConstructor().newInstance();
+ }
+ emulator.init(emulatorDefinition);
+ }
+
+ public CompletableFuture launch() {
+ final long oldestTs = emulatorDefinition.getOldestTs(System.currentTimeMillis());
+ CompletableFuture future = new CompletableFuture<>();
+ oldTelemetryExecutor.submit(() -> {
+ AtomicInteger pending = new AtomicInteger(1);
+ try {
+ if (emulator instanceof SimpleEmulator) {
+ if (oldestTs < (System.currentTimeMillis() - publishFrequency)) {
+ pushOldTelemetry(oldestTs, pending, future);
+ }
+ } else if (emulator instanceof CustomEmulator customEmulator) {
+ Pair telemetry = customEmulator.getNextValue();
+ while (telemetry != null) {
+ pending.incrementAndGet();
+ publishTelemetry(telemetry.getFirst(), telemetry.getSecond(), pending, future);
+ telemetry = customEmulator.getNextValue();
+ }
+ }
+ } catch (Exception e) {
+ log.warn("Telemetry upload failed for {}", entity.getName(), e);
+ future.completeExceptionally(e);
+ } finally {
+ if (pending.decrementAndGet() == 0 && !future.isDone()) {
+ log.trace("[{}] Telemetry emulation finished. Producer and all callbacks completed successfully", entity.getName());
+ future.complete(null);
+ }
+ }
+
+ future.whenComplete((v, t) -> {
+ try {
+ postProcessEntity(entity);
+ } catch (Exception e) {
+ log.warn("Post-processing failed for {}", entity.getName(), e);
+ }
+ });
+
+ });
+ return future;
+ }
+
+ protected void postProcessEntity(T entity) {
+ }
+
+ private void pushOldTelemetry(long oldestTs, AtomicInteger pending, CompletableFuture future) throws InterruptedException {
+ for (long ts = oldestTs; ts < System.currentTimeMillis(); ts += publishFrequency) {
+ pending.incrementAndGet();
+ publishTelemetry(ts, pending, future);
+ }
+ }
+
+ private void publishTelemetry(long ts, AtomicInteger pending, CompletableFuture future) throws InterruptedException {
+ ObjectNode values = ((SimpleEmulator) emulator).getValue(ts);
+ publishTelemetry(ts, values, pending, future);
+ Thread.sleep(emulatorDefinition.getPublishPauseInMillis());
+ }
+
+ public void stop() {
+ scheduledFuture.cancel(true);
+ }
+
+ private void publishTelemetry(long ts, ObjectNode value, AtomicInteger pending, CompletableFuture future) {
+ String msgData = JacksonUtil.toString(value);
+ log.debug("[{}] Publishing telemetry: {}", entity.getName(), msgData);
+ TbMsgMetaData md = new TbMsgMetaData();
+ md.putValue("ts", Long.toString(ts));
+ TbMsg tbMsg = TbMsg.newMsg()
+ .type(TbMsgType.POST_TELEMETRY_REQUEST)
+ .originator(entity.getId())
+ .copyMetaData(md)
+ .dataType(TbMsgDataType.JSON)
+ .data(msgData)
+ .build();
+
+ TbQueueCallback callback = new TbQueueCallback() {
+
+ @Override
+ public void onSuccess(TbQueueMsgMetadata metadata) {
+ log.debug("[{}] Successfully pushed message to Rule Engine for ts {}", entity.getName(), ts);
+ if (pending.decrementAndGet() == 0 && !future.isDone()) {
+ log.trace("[{}] Completing emulation future from callback for msg with ts: {}", entity.getName(), ts);
+ future.complete(null);
+ }
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ log.warn("[{}] Telemetry upload failed for ts {}", entity.getName(), ts, t);
+ if (pending.decrementAndGet() == 0 && !future.isDone()) {
+ future.completeExceptionally(t);
+ }
+ }
+ };
+
+ tbClusterService.pushMsgToRuleEngine(entity.getTenantId(), entity.getId(), tbMsg, callback);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java
new file mode 100644
index 0000000000..1f2d144b99
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/AssetEmulatorLauncher.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.emulator;
+
+import lombok.Builder;
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.data.asset.Asset;
+import org.thingsboard.server.queue.discovery.PartitionService;
+import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
+import org.thingsboard.server.queue.provider.TbQueueProducerProvider;
+import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition;
+import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
+
+import java.util.concurrent.ExecutorService;
+
+@Slf4j
+public class AssetEmulatorLauncher extends AbstractEmulatorLauncher {
+
+ @Builder
+ public AssetEmulatorLauncher(Asset entity, EmulatorDefinition emulatorDefinition, ExecutorService oldTelemetryExecutor, TbClusterService tbClusterService,
+ PartitionService partitionService,
+ TbQueueProducerProvider tbQueueProducerProvider,
+ TbServiceInfoProvider serviceInfoProvider,
+ TelemetrySubscriptionService tsSubService) throws Exception {
+ super(entity, emulatorDefinition, oldTelemetryExecutor, tbClusterService, partitionService, tbQueueProducerProvider, serviceInfoProvider, tsSubService);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java
new file mode 100644
index 0000000000..c81aed3c02
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/BasicEmulator.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.emulator;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition;
+import org.thingsboard.server.service.solutions.data.values.TelemetryGenerator;
+import org.thingsboard.server.service.solutions.data.values.TelemetryGeneratorFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class BasicEmulator implements SimpleEmulator {
+
+ private final Map tsGenerators = new HashMap<>();
+
+ @Override
+ public void init(EmulatorDefinition emulatorDefinition) {
+ emulatorDefinition.getTelemetryProfiles().forEach(tp -> tsGenerators.put(tp.getKey(), TelemetryGeneratorFactory.create(tp)));
+ }
+
+ @Override
+ public ObjectNode getValue(long ts) {
+ ObjectNode values = JacksonUtil.newObjectNode();
+ tsGenerators.values().forEach(gen -> gen.addValue(ts, values));
+ return values;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java
new file mode 100644
index 0000000000..627fe4e94d
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/CustomEmulator.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.emulator;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.springframework.data.util.Pair;
+
+public interface CustomEmulator extends Emulator {
+
+ Pair getNextValue();
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java
new file mode 100644
index 0000000000..62fe03aa31
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/DeviceEmulatorLauncher.java
@@ -0,0 +1,98 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.emulator;
+
+import com.google.common.util.concurrent.FutureCallback;
+import lombok.Builder;
+import lombok.extern.slf4j.Slf4j;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.thingsboard.rule.engine.api.AttributesSaveRequest;
+import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.kv.LongDataEntry;
+import org.thingsboard.server.common.msg.queue.ServiceType;
+import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.gen.transport.TransportProtos;
+import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.queue.discovery.PartitionService;
+import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
+import org.thingsboard.server.queue.provider.TbQueueProducerProvider;
+import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition;
+import org.thingsboard.server.service.state.DefaultDeviceStateService;
+import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
+
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+
+@Slf4j
+public class DeviceEmulatorLauncher extends AbstractEmulatorLauncher {
+
+ @Builder
+ public DeviceEmulatorLauncher(Device entity,
+ EmulatorDefinition emulatorDefinition,
+ ExecutorService oldTelemetryExecutor,
+ TbClusterService tbClusterService,
+ PartitionService partitionService,
+ TbQueueProducerProvider tbQueueProducerProvider,
+ TbServiceInfoProvider serviceInfoProvider,
+ TelemetrySubscriptionService tsSubService) throws Exception {
+ super(entity, emulatorDefinition, oldTelemetryExecutor, tbClusterService, partitionService, tbQueueProducerProvider, serviceInfoProvider, tsSubService);
+ }
+
+ @Override
+ protected void postProcessEntity(Device entity) {
+ if (this.emulatorDefinition.getActivityPeriodInMillis() > 0) {
+ tsSubService.saveAttributes(AttributesSaveRequest.builder()
+ .tenantId(entity.getTenantId())
+ .entityId(entity.getId())
+ .scope(AttributeScope.SERVER_SCOPE)
+ .entry(new LongDataEntry(DefaultDeviceStateService.INACTIVITY_TIMEOUT, this.emulatorDefinition.getActivityPeriodInMillis()))
+ .callback(new FutureCallback<>() {
+ @Override
+ public void onSuccess(@Nullable Void unused) {
+ TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, entity.getTenantId(), entity.getId());
+ UUID sessionId = UUID.randomUUID();
+ TransportProtos.TransportToDeviceActorMsg msg = TransportProtos.TransportToDeviceActorMsg.newBuilder()
+ .setSessionInfo(TransportProtos.SessionInfoProto.newBuilder()
+ .setSessionIdMSB(sessionId.getMostSignificantBits())
+ .setSessionIdLSB(sessionId.getLeastSignificantBits())
+ .setDeviceIdMSB(entity.getId().getId().getMostSignificantBits())
+ .setDeviceIdLSB(entity.getId().getId().getLeastSignificantBits())
+ .setDeviceProfileIdMSB(entity.getId().getId().getMostSignificantBits())
+ .setDeviceProfileIdLSB(entity.getId().getId().getLeastSignificantBits())
+ .setDeviceName(entity.getName())
+ .setDeviceType(entity.getType())
+ .setTenantIdMSB(entity.getTenantId().getId().getMostSignificantBits())
+ .setTenantIdLSB(entity.getTenantId().getId().getLeastSignificantBits())
+ .setNodeId(serviceInfoProvider.getServiceId())
+ .build())
+ .setSubscriptionInfo(TransportProtos.SubscriptionInfoProto.newBuilder().setLastActivityTime(System.currentTimeMillis()).build())
+ .build();
+ tbQueueProducerProvider.getTbCoreMsgProducer().send(tpi,
+ new TbProtoQueueMsg<>(entity.getUuidId(),
+ TransportProtos.ToCoreMsg.newBuilder().setToDeviceActorMsg(msg).build()), EMPTY_CALLBACK
+ );
+ }
+
+ @Override
+ public void onFailure(Throwable throwable) {
+ }
+ })
+ .build());
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java
new file mode 100644
index 0000000000..45a9a029b8
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/Emulator.java
@@ -0,0 +1,24 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.emulator;
+
+import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition;
+
+public interface Emulator {
+
+ void init(EmulatorDefinition emulatorDefinition);
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java
new file mode 100644
index 0000000000..44c9eb550f
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/FieldIrrigationStateEmulator.java
@@ -0,0 +1,108 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.emulator;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.springframework.data.util.Pair;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.service.solutions.data.definition.EmulatorDefinition;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.TimeZone;
+
+public class FieldIrrigationStateEmulator implements CustomEmulator {
+
+ private static final int START_HOUR = 6;
+ private int idx;
+ private List> data = new ArrayList<>();
+
+ @Override
+ public void init(EmulatorDefinition emulatorDefinition) {
+ idx = 0;
+ var tz = TimeZone.getTimeZone("America/New_York");
+ Calendar c = Calendar.getInstance();
+ c.setTimeInMillis(System.currentTimeMillis());
+ c.setTimeZone(tz);
+ int curHour = c.get(Calendar.HOUR_OF_DAY);
+ c.add(Calendar.DAY_OF_MONTH, curHour < START_HOUR ? -7 : -6);
+ c.set(Calendar.HOUR_OF_DAY, START_HOUR);
+ c.set(Calendar.MINUTE, 0);
+
+ add(c, JacksonUtil.newObjectNode()
+ .put("startTs", c.getTimeInMillis())
+ .put("durationThreshold", 30 * 60000)
+ .put("consumption", 423)
+ .put("duration", 30 * 60000)
+ );
+ add(c, JacksonUtil.newObjectNode()
+ .put("startTs", c.getTimeInMillis())
+ .put("durationThreshold", 30 * 60000)
+ .put("consumption", 407)
+ .put("duration", 30 * 60000)
+ );
+ add(c, JacksonUtil.newObjectNode()
+ .put("startTs", c.getTimeInMillis())
+ .put("consumptionThreshold", 1000)
+ .put("consumption", 1000)
+ .put("duration", 63 * 60000)
+ );
+ add(c, JacksonUtil.newObjectNode()
+ .put("startTs", c.getTimeInMillis())
+ .put("consumptionThreshold", 500)
+ .put("consumption", 500)
+ .put("duration", 36 * 60000)
+ );
+ add(c, JacksonUtil.newObjectNode()
+ .put("startTs", c.getTimeInMillis())
+ .put("consumptionThreshold", 1000)
+ .put("consumption", 1000)
+ .put("duration", 63 * 60000)
+ );
+ add(c, JacksonUtil.newObjectNode()
+ .put("startTs", c.getTimeInMillis())
+ .put("durationThreshold", 30 * 60000)
+ .put("duration", 30 * 60000)
+ .put("consumption", 452)
+ );
+ add(c, JacksonUtil.newObjectNode()
+ .put("startTs", c.getTimeInMillis())
+ .put("durationThreshold", 30 * 60000)
+ .put("duration", 30 * 60000)
+ .put("consumption", 447)
+ );
+ }
+
+ private void add(Calendar c, ObjectNode objectNode) {
+ ObjectNode startIrrigationMsg = JacksonUtil.newObjectNode();
+ startIrrigationMsg.put("irrigationState", "DONE");
+ startIrrigationMsg.set("irrigationTask", objectNode);
+ data.add(Pair.of(c.getTimeInMillis(), startIrrigationMsg));
+ c.add(Calendar.DAY_OF_MONTH, 1);
+ }
+
+ @Override
+ public Pair getNextValue() {
+ if (idx < data.size()) {
+ var result = data.get(idx);
+ idx++;
+ return result;
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java
new file mode 100644
index 0000000000..144a5f7f26
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/emulator/SimpleEmulator.java
@@ -0,0 +1,24 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.emulator;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+public interface SimpleEmulator extends Emulator {
+
+ ObjectNode getValue(long ts);
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java
new file mode 100644
index 0000000000..ca66c37199
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameData.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.names;
+
+import lombok.Data;
+
+@Data
+public class RandomNameData {
+
+ private final String firstName;
+ private final String lastName;
+ private final String email;
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java
new file mode 100644
index 0000000000..50bc3b9dc5
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/names/RandomNameUtil.java
@@ -0,0 +1,100 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.names;
+
+import org.thingsboard.server.common.data.StringUtils;
+
+import java.util.Random;
+
+public class RandomNameUtil {
+
+ private static final Random RANDOM = new Random(System.currentTimeMillis());
+
+ private static String[] FIRST_NAMES = new String[]{
+ "Nadia", "Lynne", "Lera", "Holly", "Zina", "Mandi", "Kasie", "Josef", "Liane", "Man",
+ "Karan", "Nga", "Tesha", "Elva", "Bree", "Ida", "Mimi", "Altha", "Omar", "Ollie",
+ "Nicol", "Ned", "Teri", "Sabra", "Kari", "Eva", "Penni", "Simon", "Kaci", "Jess",
+ "Bea", "Ron", "Melba", "Vella", "Nada", "Cyndi", "Audry", "Helga", "Chana", "Lola",
+ "Terry", "Tami", "Dedra", "Erwin", "Anne", "Zoila", "Nelia", "Hyman", "Deane", "Erica",
+ "Amos", "Maura", "Gwenn", "Evan", "Lelia", "Grant", "Abe", "Fanny", "Cindi", "Pilar",
+ "Darcy", "Len", "Casie", "Jose", "Kylie", "Cami", "Casey", "Kerri", "Bruno", "Theda",
+ "Ardis", "Carin", "Belia", "Jeff", "Yetta", "Ola", "Lyla", "Megan", "Zita", "Rocky",
+ "Darci", "Dale", "Mirta", "Tanja", "Stacy", "Julie", "Aisha", "Avis", "Hugh", "Anh",
+ "Bud", "Earle", "Ossie", "Odell", "Dovie", "Afton", "Joel", "Aida", "Vina", "Emiko",
+ "Tori", "Keena", "Addie", "Nancy", "Tonda", "Josue", "Dina", "Mitch", "Thea", "Cole",
+ "Lyman", "Donna", "Roma", "Deena", "Lue", "Bette", "Ilene", "Vera", "Kirby", "Lenna",
+ "Pat", "Grace", "Gus", "Frank", "Lena", "Adele", "Kerry", "Santo", "Wade", "Trudi",
+ "Bruce", "Raul", "Katy", "Heidi", "Marna", "Faith", "Ronna", "Kylee", "Emma", "Luna",
+ "Ilda", "Mose", "Juan", "Cori", "Gayla", "Otha", "Jung", "Bambi", "Joi", "Sibyl",
+ "Reid", "Bonny", "Roni", "Joy", "Farah", "Jeane", "Jill", "Tisha", "Leon", "Leigh",
+ "Inge", "Zella", "Rubin", "Carri", "Nana", "Sofia", "Lucio", "Eboni", "Adam", "Lino",
+ "Elvis", "Marci", "Adina", "Nanci", "Joyce", "James", "Louis", "Aurea", "Chi", "Kathy",
+ "Nina", "Lise", "Reda", "Candi", "Ralph", "Velva", "Mari", "Mavis", "Kory", "Tammi",
+ "Lucy", "Lavon", "Ana", "Lin", "Alena", "Haley", "Myra", "Amy", "Marlo", "Ami"
+ };
+
+ private static String[] LAST_NAMES = new String[]{
+ "Vinson", "Rogers", "Burkett", "Gamboa", "Gross", "Toledo", "Kiser", "Harman", "Pierce", "Lovett",
+ "Sexton", "Coates", "Seymour", "Holland", "Finley", "Hagen", "Schmitt", "Beach", "Rea", "Peck",
+ "Noel", "Archer", "Zamora", "Wood", "Rivera", "Lentz", "Alonso", "Rider", "Story", "Schwab",
+ "Mercado", "Caruso", "Bynum", "Spears", "Bingham", "Tyler", "Ponce", "Skaggs", "Matos", "Stinson",
+ "Burgos", "Coles", "Helton", "Joyce", "Galvan", "Grant", "Stahl", "Daigle", "Hayden", "Quiroz",
+ "Horne", "Shaw", "Addison", "Harvey", "Watkins", "Romano", "Ahmed", "Lind", "Swenson", "Kemp",
+ "Barnes", "Horton", "Dugan", "Farr", "Swanson", "Hale", "Hidalgo", "Schulte", "Ervin", "Osorio",
+ "Lincoln", "Zhang", "Oakes", "Stiles", "Lu", "Culver", "Singer", "Knott", "Bullard", "Butts",
+ "Coley", "Roth", "Cobb", "Darnell", "Vaughan", "Conrad", "Austin", "Rico", "Hobson", "Brunner",
+ "Mccray", "Mattson", "Barber", "Clement", "Ly", "Dejesus", "Painter", "Macias", "Healy", "Duncan",
+ "Forrest", "Hurley", "Torres", "Blue", "Rucker", "Ferrell", "Maddox", "Osborne", "Holt", "Briggs",
+ "Irvin", "Bassett", "Urban", "Overton", "Sloan", "Guzman", "Estrada", "Redding", "Gold", "Paz",
+ "Lucero", "Reyes", "Conner", "Keith", "Hays", "Hutton", "Moore", "Tuttle", "Powers", "William",
+ "Reeder", "Castro", "Vela", "Baca", "Frazier", "Kline", "Huggins", "Zepeda", "Pate", "Marion",
+ "Hollis", "Ybarra", "Cuellar", "Gallo", "Gomes", "Louis", "Bolden", "Liu", "Hayes", "Simpson",
+ "Otero", "Conway", "Peoples", "Mohr", "Loomis", "Engel", "Hughes", "Chapman", "Bower", "Schmidt",
+ "Mcmahon", "Booth", "Ward", "Drake", "Cutler", "Gordon", "Black", "Clifton", "Curran", "Pierre",
+ "Workman", "Tilley", "Moody", "Kuhn", "Eastman", "Scott", "Vang", "Tillman", "Cleary", "Yi",
+ "Griffin", "Mendoza", "Mason", "Pittman", "Landis", "Gay", "Rowland", "Jordan", "Lo", "Kumar",
+ "Vernon", "Boyd", "Rocha", "Mcqueen", "Cornett", "Deaton", "Borden", "Lacey", "Gaston", "Miles"
+ };
+
+ public static String nextFirstName() {
+ return randomElement(FIRST_NAMES);
+ }
+
+ public static String nextLastName() {
+ return randomElement(LAST_NAMES);
+ }
+
+ private static String toEmail(String firstName, String lastName) {
+ return firstName.toLowerCase() + "." + lastName.toLowerCase() + "@thingsboard.io";
+ }
+
+ public static RandomNameData next() {
+ var firstName = nextFirstName();
+ var lastName = nextLastName();
+ return new RandomNameData(firstName, lastName, toEmail(firstName, lastName));
+ }
+
+ public static RandomNameData nextSuperRandom() {
+ var firstName = nextFirstName() + StringUtils.randomAlphanumeric(10).toLowerCase();
+ var lastName = nextLastName() + StringUtils.randomAlphanumeric(10).toLowerCase();
+ return new RandomNameData(firstName, lastName, toEmail(firstName, lastName));
+ }
+
+ private static String randomElement(String[] array) {
+ return array[RANDOM.nextInt(array.length)];
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java
new file mode 100644
index 0000000000..a122a562a9
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/SolutionInstallResponse.java
@@ -0,0 +1,54 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.solution;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EntityId;
+
+import java.util.Collections;
+import java.util.List;
+
+@Schema
+@Data
+public class SolutionInstallResponse extends TenantSolutionTemplateInstructions {
+
+ @Schema(description = "Indicates that template was installed successfully")
+ private boolean success;
+ @Schema(description = "List of entity IDs created during solution installation")
+ private List createdEntityIds;
+ @Schema(description = "What keys to delete during template uninstall")
+ private List tenantTelemetryKeys;
+ @Schema(description = "What attributes to delete during template uninstall")
+ private List tenantAttributeKeys;
+
+ public SolutionInstallResponse(TenantSolutionTemplateInstructions instructions, boolean success, List createdEntityIds) {
+ this(instructions, success, createdEntityIds, Collections.emptyList(), Collections.emptyList());
+ }
+
+ public SolutionInstallResponse(TenantSolutionTemplateInstructions instructions, boolean success, List createdEntityIds,
+ List tenantTelemetryKeys, List tenantAttributeKeys) {
+ super(instructions);
+ this.success = success;
+ this.createdEntityIds = createdEntityIds;
+ this.tenantTelemetryKeys = tenantTelemetryKeys;
+ this.tenantAttributeKeys = tenantAttributeKeys;
+ }
+
+ public SolutionInstallResponse() {
+ super();
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java
new file mode 100644
index 0000000000..fabd89d072
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/solution/TenantSolutionTemplateInstructions.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.solution;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.DashboardId;
+
+@Schema
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class TenantSolutionTemplateInstructions {
+
+ @Schema(description = "Id of the main dashboard of the solution")
+ private DashboardId dashboardId;
+ @Schema(description = "Id of the public customer if solution has public entities")
+ private CustomerId publicId;
+ @Schema(description = "Is the main dashboard public")
+ private boolean mainDashboardPublic;
+ @Schema(description = "Markdown with solution usage instructions")
+ private String details;
+
+ public TenantSolutionTemplateInstructions(TenantSolutionTemplateInstructions instructions) {
+ this.dashboardId = instructions.getDashboardId();
+ this.publicId = instructions.getPublicId();
+ this.mainDashboardPublic = instructions.isMainDashboardPublic();
+ this.details = instructions.getDetails();
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java
new file mode 100644
index 0000000000..ba803faf87
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyDefinition.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class CompositeValueStrategyDefinition implements ValueStrategyDefinition {
+
+ private ValueStrategyDefinition defaultHours;
+ private ValueStrategyDefinition workHours;
+ private ValueStrategyDefinition nightHours;
+ private ValueStrategyDefinition holidayHours;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.COMPOSITE;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java
new file mode 100644
index 0000000000..6cbfaf550e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CompositeValueStrategyGenerator.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+public class CompositeValueStrategyGenerator extends TelemetryGenerator {
+
+ TelemetryGenerator defaultGenerator;
+ TelemetryGenerator whGenerator;
+ TelemetryGenerator nhGenerator;
+ TelemetryGenerator hhGenerator;
+
+ public CompositeValueStrategyGenerator(TelemetryProfile tp) {
+ super(tp);
+ var def = (CompositeValueStrategyDefinition) tp.getValueStrategy();
+ defaultGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getDefaultHours()));
+ if (def.getWorkHours() != null) {
+ whGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getWorkHours()));
+ }
+ if (def.getNightHours() != null) {
+ nhGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getNightHours()));
+ }
+ if (def.getHolidayHours() != null) {
+ hhGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getHolidayHours()));
+ }
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+ if (hhGenerator != null && GeneratorTools.isHoliday(ts)) {
+ hhGenerator.addValue(ts, values);
+ } else if (nhGenerator != null && GeneratorTools.isNightHour(ts)) {
+ nhGenerator.addValue(ts, values);
+ } else if (whGenerator != null && GeneratorTools.isWorkHour(ts)) {
+ whGenerator.addValue(ts, values);
+ } else {
+ defaultGenerator.addValue(ts, values);
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java
new file mode 100644
index 0000000000..59ca319f67
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantTelemetryGenerator.java
@@ -0,0 +1,51 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+public class ConstantTelemetryGenerator extends TelemetryGenerator {
+
+ private final ConstantValueStrategyDefinition strategy;
+ private JsonNode value;
+
+ public ConstantTelemetryGenerator(TelemetryProfile telemetryProfile) {
+ super(telemetryProfile);
+ this.strategy = (ConstantValueStrategyDefinition) telemetryProfile.getValueStrategy();
+ this.value = strategy.getValue();
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+ values.set(key, value);
+ }
+
+ @Override
+ public double getValue() {
+ if (value != null && value.isNumber()) {
+ return value.doubleValue();
+ } else {
+ return super.getValue();
+ }
+ }
+
+ @Override
+ public void setValue(double value) {
+ // do nothing;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.java
new file mode 100644
index 0000000000..ef18ef2c9b
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ConstantValueStrategyDefinition.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ConstantValueStrategyDefinition implements ValueStrategyDefinition {
+ private JsonNode value;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.CONSTANT;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java
new file mode 100644
index 0000000000..544d143893
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterTelemetryGenerator.java
@@ -0,0 +1,69 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.Getter;
+import lombok.Setter;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.getMultiplier;
+import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble;
+
+public class CounterTelemetryGenerator extends TelemetryGenerator {
+
+ private final CounterValueStrategyDefinition strategy;
+ @Getter @Setter
+ private double value;
+
+ public CounterTelemetryGenerator(TelemetryProfile telemetryProfile) {
+ super(telemetryProfile);
+ this.strategy = (CounterValueStrategyDefinition) telemetryProfile.getValueStrategy();
+ this.value = getRandomStartValue();
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+ double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement());
+ double multiplier = getMultiplier(ts, strategy.getHolidayMultiplier(), strategy.getWorkHoursMultiplier(), strategy.getNightHoursMultiplier());
+ value += step * multiplier;
+ if (value > getRandomEndValue()) {
+ value = getRandomStartValue();
+ }
+ put(values, value);
+ }
+
+ private void put(ObjectNode values, double value) {
+ if (strategy.getPrecision() == 0) {
+ values.put(key, (int) value);
+ } else {
+ values.put(key, BigDecimal.valueOf(value)
+ .setScale(strategy.getPrecision(), RoundingMode.HALF_UP)
+ .doubleValue());
+ }
+ }
+
+ public double getRandomStartValue() {
+ return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue());
+ }
+
+ public double getRandomEndValue() {
+ return randomDouble(strategy.getMinEndValue(), strategy.getMaxEndValue());
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java
new file mode 100644
index 0000000000..9010af51bd
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/CounterValueStrategyDefinition.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class CounterValueStrategyDefinition implements ValueStrategyDefinition {
+
+ private int precision;
+ private double minStartValue;
+ private double maxStartValue;
+ private double minEndValue;
+ private double maxEndValue;
+ private double minIncrement;
+ private double maxIncrement;
+ private double holidayMultiplier;
+ private double workHoursMultiplier;
+ private double nightHoursMultiplier;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.COUNTER;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.java
new file mode 100644
index 0000000000..bbcb6cedf7
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementTelemetryGenerator.java
@@ -0,0 +1,37 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble;
+
+public class DecrementTelemetryGenerator extends IncDecTelemetryGenerator {
+
+ public DecrementTelemetryGenerator(TelemetryProfile telemetryProfile) {
+ super(telemetryProfile);
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+ double step = randomDouble(strategy.getMinDecrement(), strategy.getMaxDecrement());
+ double newValue = value - step;
+ value = Math.max(newValue, endValue);
+ put(values, value);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java
new file mode 100644
index 0000000000..b5f254e2e5
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/DecrementValueStrategyDefinition.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class DecrementValueStrategyDefinition extends IncDecValueStrategyDefinition {
+
+ private double minDecrement;
+ private double maxDecrement;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.DECREMENT;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java
new file mode 100644
index 0000000000..82555f7ed4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventTelemetryGenerator.java
@@ -0,0 +1,54 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomLong;
+
+
+public class EventTelemetryGenerator extends TelemetryGenerator {
+
+ private final EventValueStrategyDefinition strategy;
+ private final long currentAnomaly;
+ private Object value;
+
+ public EventTelemetryGenerator(TelemetryProfile telemetryProfile) {
+ super(telemetryProfile);
+ this.strategy = (EventValueStrategyDefinition) telemetryProfile.getValueStrategy();
+ currentAnomaly = randomLong(0, strategy.getAnomalyChance());
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+ boolean anomaly = false;
+ if (randomLong(0, strategy.getAnomalyChance()) == currentAnomaly) {
+ anomaly = true;
+ }
+ this.value = anomaly ? strategy.getAnomalyValue() : strategy.getNormalValue();
+ if (value instanceof Boolean) {
+ values.put(key, (Boolean) value);
+ } else if (value instanceof Double) {
+ values.put(key, ((Double) value));
+ } else if (value instanceof Integer) {
+ values.put(key, ((Integer) value));
+ } else {
+ throw new RuntimeException("Not supported value for event telemetry generator: " + value);
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java
new file mode 100644
index 0000000000..855004da02
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/EventValueStrategyDefinition.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class EventValueStrategyDefinition implements ValueStrategyDefinition {
+ private Object normalValue;
+ private Object anomalyValue;
+ private double anomalyChance;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.EVENT;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java
new file mode 100644
index 0000000000..918379faa6
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/GeneratorTools.java
@@ -0,0 +1,96 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Random;
+import java.util.TimeZone;
+
+public class GeneratorTools {
+
+ private static final Random random = new Random();
+
+ public static long randomLong(double min, double max) {
+ return (long) randomDouble(min, max);
+ }
+
+ public static double randomDouble(double min, double max) {
+ return min + random.nextDouble() * Math.abs(max - min);
+ }
+
+ public static double getMultiplier(long ts, double holidayMultiplier, double workHoursMultiplier, double nightHoursMultiplier) {
+ Date date = new Date(ts);
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ int dayOfWeek = c.get(Calendar.DAY_OF_WEEK);
+ int hour = c.get(Calendar.HOUR_OF_DAY);
+ double multiplier = 1.0;
+ if (dayOfWeek == 1 || dayOfWeek == 7) {
+ multiplier *= holidayMultiplier;
+ }
+
+ if (hour > 8 && hour < 18) {
+ multiplier *= workHoursMultiplier;
+ } else if (hour < 6 || hour > 22) {
+ multiplier *= nightHoursMultiplier;
+ }
+ return multiplier;
+ }
+
+ public static int getHour(TimeZone tz, long ts) {
+ Date date = new Date(ts);
+ Calendar c = Calendar.getInstance();
+ c.setTimeZone(tz);
+ c.setTime(date);
+ return c.get(Calendar.HOUR_OF_DAY);
+ }
+
+ public static int getMinute(TimeZone tz, long ts) {
+ Date date = new Date(ts);
+ Calendar c = Calendar.getInstance();
+ c.setTimeZone(tz);
+ c.setTime(date);
+ return c.get(Calendar.MINUTE);
+ }
+
+ public static boolean isHoliday(long ts) {
+ Date date = new Date(ts);
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ int dayOfWeek = c.get(Calendar.DAY_OF_WEEK);
+ return dayOfWeek == 1 || dayOfWeek == 7;
+ }
+
+ public static boolean isWorkHour(long ts) {
+ Date date = new Date(ts);
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ int hour = c.get(Calendar.HOUR_OF_DAY);
+
+ return hour > 8 && hour < 18;
+ }
+
+ public static boolean isNightHour(long ts) {
+ Date date = new Date(ts);
+ Calendar c = Calendar.getInstance();
+ c.setTime(date);
+ int hour = c.get(Calendar.HOUR_OF_DAY);
+
+ return hour < 6 || hour >= 22;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.java
new file mode 100644
index 0000000000..09d7e2dce2
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecTelemetryGenerator.java
@@ -0,0 +1,64 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.Getter;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble;
+
+public abstract class IncDecTelemetryGenerator extends TelemetryGenerator {
+
+ protected final T strategy;
+ @Getter
+ protected double value;
+ protected double endValue;
+
+ @SuppressWarnings("unchecked")
+ public IncDecTelemetryGenerator(TelemetryProfile telemetryProfile) {
+ super(telemetryProfile);
+ this.strategy = (T) telemetryProfile.getValueStrategy();
+ this.value = getRandomStartValue();
+ this.endValue = getRandomEndValue();
+ }
+
+ public void setValue(double value) {
+ this.value = value;
+ this.endValue = getRandomEndValue();
+ }
+
+ protected void put(ObjectNode values, double value) {
+ if (strategy.getPrecision() == 0) {
+ values.put(key, (int) value);
+ } else {
+ values.put(key, BigDecimal.valueOf(value)
+ .setScale(strategy.getPrecision(), RoundingMode.HALF_UP)
+ .doubleValue());
+ }
+ }
+
+ public double getRandomStartValue() {
+ return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue());
+ }
+
+ public double getRandomEndValue() {
+ return randomDouble(strategy.getMinEndValue(), strategy.getMaxEndValue());
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java
new file mode 100644
index 0000000000..adc3100dab
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncDecValueStrategyDefinition.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public abstract class IncDecValueStrategyDefinition implements ValueStrategyDefinition {
+
+ private int precision;
+ private double minStartValue;
+ private double maxStartValue;
+ private double minEndValue;
+ private double maxEndValue;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java
new file mode 100644
index 0000000000..d18f36017f
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementTelemetryGenerator.java
@@ -0,0 +1,36 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble;
+
+public class IncrementTelemetryGenerator extends IncDecTelemetryGenerator {
+
+ public IncrementTelemetryGenerator(TelemetryProfile telemetryProfile) {
+ super(telemetryProfile);
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+ double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement());
+ double newValue = value + step;
+ value = Math.min(newValue, endValue);
+ put(values, value);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java
new file mode 100644
index 0000000000..64d7bc0b4c
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/IncrementValueStrategyDefinition.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class IncrementValueStrategyDefinition extends IncDecValueStrategyDefinition {
+
+ private double minIncrement;
+ private double maxIncrement;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.INCREMENT;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java
new file mode 100644
index 0000000000..927eb5a76f
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalTelemetryGenerator.java
@@ -0,0 +1,94 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.Getter;
+import lombok.Setter;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.getMultiplier;
+import static org.thingsboard.server.service.solutions.data.values.GeneratorTools.randomDouble;
+
+public class NaturalTelemetryGenerator extends TelemetryGenerator {
+
+ private final NaturalValueStrategyDefinition strategy;
+ @Getter @Setter
+ private double value;
+ private boolean isIncrement;
+ private double lowValue;
+ private double highValue;
+
+ public NaturalTelemetryGenerator(TelemetryProfile telemetryProfile) {
+ super(telemetryProfile);
+ this.strategy = (NaturalValueStrategyDefinition) telemetryProfile.getValueStrategy();
+ this.value = getRandomStartValue();
+ this.lowValue = getRandomLowValue();
+ this.highValue = getRandomHighValue();
+ isIncrement = !strategy.isDecrementOnStart();
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+
+ double multiplier = getMultiplier(ts, strategy.getHolidayMultiplier(), strategy.getWorkHoursMultiplier(), strategy.getNightHoursMultiplier());
+
+ if (isIncrement) {
+ double step = randomDouble(strategy.getMinIncrement(), strategy.getMaxIncrement());
+ value += step * multiplier;
+ if (value > highValue) {
+ value = highValue;
+ highValue = getRandomHighValue();
+ isIncrement = false;
+ }
+ } else {
+ double step = randomDouble(strategy.getMinDecrement(), strategy.getMaxDecrement());
+ value -= step * multiplier;
+ if (value < lowValue) {
+ value = lowValue;
+ lowValue = getRandomLowValue();
+ isIncrement = true;
+ }
+ }
+
+ put(values, value);
+ }
+
+ private void put(ObjectNode values, double value) {
+ if (strategy.getPrecision() == 0) {
+ values.put(key, (int) value);
+ } else {
+ values.put(key, BigDecimal.valueOf(value)
+ .setScale(strategy.getPrecision(), RoundingMode.HALF_UP)
+ .doubleValue());
+ }
+ }
+
+ public double getRandomStartValue() {
+ return randomDouble(strategy.getMinStartValue(), strategy.getMaxStartValue());
+ }
+
+ public double getRandomLowValue() {
+ return randomDouble(strategy.getMinLowValue(), strategy.getMaxLowValue());
+ }
+
+ public double getRandomHighValue() {
+ return randomDouble(strategy.getMinHighValue(), strategy.getMaxHighValue());
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java
new file mode 100644
index 0000000000..3141baa47a
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/NaturalValueStrategyDefinition.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class NaturalValueStrategyDefinition implements ValueStrategyDefinition {
+ private int precision;
+ private double minStartValue;
+ private double maxStartValue;
+ private double minLowValue;
+ private double maxLowValue;
+ private double minHighValue;
+ private double maxHighValue;
+ private double minIncrement;
+ private double maxIncrement;
+ private double minDecrement;
+ private double maxDecrement;
+ private double holidayMultiplier;
+ private double workHoursMultiplier;
+ private double nightHoursMultiplier;
+ private boolean decrementOnStart;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.NATURAL;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java
new file mode 100644
index 0000000000..4804863bd3
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyDefinition.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ScheduleValueStrategyDefinition implements ValueStrategyDefinition {
+
+ private List schedule;
+ private String timeZone;
+ private ValueStrategyDefinition defaultDefinition;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.SCHEDULE;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java
new file mode 100644
index 0000000000..1d067a3044
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ScheduleValueStrategyGenerator.java
@@ -0,0 +1,70 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TimeZone;
+
+public class ScheduleValueStrategyGenerator extends TelemetryGenerator {
+
+ private TelemetryGenerator defaultGenerator;
+ private TimeZone timeZone;
+ private Map scheduleGenerators;
+ private TelemetryGenerator prevGenerator;
+
+ public ScheduleValueStrategyGenerator(TelemetryProfile tp) {
+ super(tp);
+ var def = (ScheduleValueStrategyDefinition) tp.getValueStrategy();
+ timeZone = TimeZone.getTimeZone(def.getTimeZone());
+ defaultGenerator = TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), def.getDefaultDefinition()));
+ scheduleGenerators = new LinkedHashMap<>();
+ for (ValueStrategySchedule scheduleItem : def.getSchedule()) {
+ scheduleGenerators.put(scheduleItem, TelemetryGeneratorFactory.create(new TelemetryProfile(tp.getKey(), scheduleItem.getDefinition())));
+ }
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+ int hour = GeneratorTools.getHour(timeZone, ts);
+ int minute = GeneratorTools.getMinute(timeZone, ts);
+ TelemetryGenerator generator = scheduleGenerators.entrySet().stream().filter(pair -> {
+ var schedule = pair.getKey();
+ if (hour == schedule.getStartHour() && hour == schedule.getEndHour()) {
+ return schedule.getStartMinute() <= minute && minute <= schedule.getEndMinute();
+ } else if (hour == schedule.getStartHour() && hour < schedule.getEndHour()) {
+ return schedule.getStartMinute() <= minute;
+ } else if (hour > schedule.getStartHour() && hour < schedule.getEndHour()) {
+ return true;
+ } else if (hour > schedule.getStartHour() && hour == schedule.getEndHour()) {
+ return minute <= schedule.getEndHour();
+ } else {
+ return false;
+ }
+ }).map(Map.Entry::getValue).findFirst().orElse(defaultGenerator);
+
+ if (prevGenerator != null && !prevGenerator.equals(generator)) {
+ generator.setValue(prevGenerator.getValue());
+ }
+
+ generator.addValue(ts, values);
+
+ prevGenerator = generator;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java
new file mode 100644
index 0000000000..d523715d67
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyDefinition.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.Data;
+
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class SequenceValueStrategyDefinition implements ValueStrategyDefinition {
+ private boolean random;
+ private ObjectNode telemetry;
+
+ @Override
+ public ValueStrategyDefinitionType getStrategyType() {
+ return ValueStrategyDefinitionType.SEQUENCE;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java
new file mode 100644
index 0000000000..d7c75025f4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/SequenceValueStrategyGenerator.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+import java.util.Random;
+
+public class SequenceValueStrategyGenerator extends TelemetryGenerator {
+
+ private final SequenceValueStrategyDefinition strategy;
+ private int max;
+ private int index;
+
+ public SequenceValueStrategyGenerator(TelemetryProfile telemetryProfile) {
+ super(telemetryProfile);
+ this.strategy = (SequenceValueStrategyDefinition) telemetryProfile.getValueStrategy();
+ max = strategy.getTelemetry().fields().next().getValue().size() - 1;
+ index = strategy.isRandom() ? new Random().nextInt(max + 1) : 0;
+
+ }
+
+ @Override
+ public void addValue(long ts, ObjectNode values) {
+ strategy.getTelemetry().fields().forEachRemaining(entry -> {
+ String key = entry.getKey();
+ values.set(key, entry.getValue().get(index));
+ });
+ index++;
+ if (index > max) {
+ index = 0;
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java
new file mode 100644
index 0000000000..5024f4e590
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGenerator.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.Data;
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+@Data
+public abstract class TelemetryGenerator {
+ protected final TelemetryProfile profile;
+ protected final String key;
+
+ public TelemetryGenerator(TelemetryProfile telemetryProfile) {
+ this.profile = telemetryProfile;
+ this.key = telemetryProfile.getKey();
+ }
+
+ public double getValue() {
+ throw new RuntimeException("Not supported");
+ }
+
+ public void setValue(double value) {
+ throw new RuntimeException("Not supported");
+ }
+
+ public abstract void addValue(long ts, ObjectNode values);
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java
new file mode 100644
index 0000000000..17d29ce3e1
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/TelemetryGeneratorFactory.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+
+import org.thingsboard.server.service.solutions.data.definition.TelemetryProfile;
+
+public class TelemetryGeneratorFactory {
+
+ public static TelemetryGenerator create(TelemetryProfile tp) {
+ switch (tp.getValueStrategy().getStrategyType()) {
+ case COUNTER:
+ return new CounterTelemetryGenerator(tp);
+ case NATURAL:
+ return new NaturalTelemetryGenerator(tp);
+ case EVENT:
+ return new EventTelemetryGenerator(tp);
+ case CONSTANT:
+ return new ConstantTelemetryGenerator(tp);
+ case SEQUENCE:
+ return new SequenceValueStrategyGenerator(tp);
+ case COMPOSITE:
+ return new CompositeValueStrategyGenerator(tp);
+ case SCHEDULE:
+ return new ScheduleValueStrategyGenerator(tp);
+ case INCREMENT:
+ return new IncrementTelemetryGenerator(tp);
+ case DECREMENT:
+ return new DecrementTelemetryGenerator(tp);
+ default:
+ throw new RuntimeException("Not supported!");
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.java
new file mode 100644
index 0000000000..34d5f9b5e7
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinition.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "type")
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = CounterValueStrategyDefinition.class, name = "counter"),
+ @JsonSubTypes.Type(value = NaturalValueStrategyDefinition.class, name = "natural"),
+ @JsonSubTypes.Type(value = EventValueStrategyDefinition.class, name = "event"),
+ @JsonSubTypes.Type(value = SequenceValueStrategyDefinition.class, name = "sequence"),
+ @JsonSubTypes.Type(value = ConstantValueStrategyDefinition.class, name = "constant"),
+ @JsonSubTypes.Type(value = CompositeValueStrategyDefinition.class, name = "composite"),
+ @JsonSubTypes.Type(value = ScheduleValueStrategyDefinition.class, name = "schedule"),
+ @JsonSubTypes.Type(value = IncrementValueStrategyDefinition.class, name = "inc"),
+ @JsonSubTypes.Type(value = DecrementValueStrategyDefinition.class, name = "dec")})
+public interface ValueStrategyDefinition {
+
+ ValueStrategyDefinitionType getStrategyType();
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java
new file mode 100644
index 0000000000..a561d6469e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategyDefinitionType.java
@@ -0,0 +1,22 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+public enum ValueStrategyDefinitionType {
+
+ COUNTER, NATURAL, EVENT, SEQUENCE, CONSTANT, COMPOSITE, SCHEDULE, INCREMENT, DECREMENT;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.java
new file mode 100644
index 0000000000..0bb2ccf8fa
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/values/ValueStrategySchedule.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright © 2016-2026 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.solutions.data.values;
+
+import lombok.Data;
+
+@Data
+public class ValueStrategySchedule {
+
+ private int startHour;
+ private int startMinute;
+
+ private int endHour;
+ private int endMinute;
+
+ private ValueStrategyDefinition definition;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
index a256888107..80d857c296 100644
--- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
+++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
@@ -35,6 +35,8 @@ import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
+import org.thingsboard.server.service.install.lts.LtsMigrationService;
+import org.thingsboard.server.service.install.lts.LtsVersion;
import org.thingsboard.server.service.install.update.DefaultDataUpdateService;
import java.io.IOException;
@@ -74,6 +76,7 @@ public class SystemPatchApplier {
private final WidgetTypeService widgetTypeService;
private final WidgetsBundleService widgetsBundleService;
private final ImageService imageService;
+ private final LtsMigrationService ltsMigrationService;
@PostConstruct
private void init() {
@@ -101,7 +104,9 @@ public class SystemPatchApplier {
}
try {
- updateLtsSqlSchema();
+ String dbVersion = schemaSettingsService.getDbSchemaVersion();
+ String packageVersion = schemaSettingsService.getPackageSchemaVersion();
+ ltsMigrationService.applyMigrations(dbVersion, packageVersion);
updateSqlViews();
log.info("Updated sql database views");
@@ -129,15 +134,17 @@ public class SystemPatchApplier {
log.trace("Package version: {}, DB schema version: {}", packageVersion, dbVersion);
- VersionInfo packageVersionInfo = parseVersion(packageVersion);
- VersionInfo dbVersionInfo = parseVersion(dbVersion);
-
- if (packageVersionInfo == null || dbVersionInfo == null) {
+ LtsVersion packageVersionInfo;
+ LtsVersion dbVersionInfo;
+ try {
+ packageVersionInfo = LtsVersion.parse(packageVersion);
+ dbVersionInfo = LtsVersion.parse(dbVersion);
+ } catch (IllegalArgumentException e) {
log.warn("Unable to parse versions. Package: {}, DB: {}", packageVersion, dbVersion);
return false;
}
- if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) {
+ if (!packageVersionInfo.sameFamily(dbVersionInfo) || packageVersionInfo.compareTo(dbVersionInfo) <= 0) {
return false;
}
@@ -145,31 +152,6 @@ public class SystemPatchApplier {
return true;
}
- private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) {
- if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) {
- return false;
- }
- if (packageVersion.maintenance != dbVersion.maintenance) {
- return packageVersion.maintenance > dbVersion.maintenance;
- }
- return packageVersion.patch > dbVersion.patch;
- }
-
- private void updateLtsSqlSchema() {
- Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql");
- if (!Files.exists(sqlFile)) {
- log.trace("LTS schema update file does not exist: {}", sqlFile);
- return;
- }
- try {
- String sql = Files.readString(sqlFile);
- jdbcTemplate.execute(sql);
- log.info("Applied LTS SQL schema update from {}", sqlFile);
- } catch (IOException e) {
- throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e);
- }
- }
-
private void updateSqlViews() {
try {
URL schemaViewsUrl = Resources.getResource(SCHEMA_VIEWS_SQL);
@@ -428,20 +410,6 @@ public class SystemPatchApplier {
}
}
- private VersionInfo parseVersion(String version) {
- try {
- String[] parts = version.split("\\.");
- int major = Integer.parseInt(parts[0]);
- int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
- int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
- int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
- return new VersionInfo(major, minor, maintenance, patch);
- } catch (Exception e) {
- log.error("Failed to parse version: {}", version, e);
- return null;
- }
- }
-
private Stream listDir(Path dir) {
try {
return Files.list(dir);
@@ -452,8 +420,6 @@ public class SystemPatchApplier {
}
}
- public record VersionInfo(int major, int minor, int maintenance, int patch) {}
-
public record WidgetTypeStats(int created, int updated) {}
private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED }
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 454c59d7d7..64d5a66a58 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -1158,6 +1158,8 @@ transport:
timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}"
# Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property)
api_enabled: "${TB_TRANSPORT_API_ENABLED:true}"
+ # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
+ callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
log:
# Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update
enabled: "${TB_TRANSPORT_LOG_ENABLED:true}"
@@ -2192,3 +2194,12 @@ mqtt:
# 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}"
+iot-hub:
+ base-url: "${IOT_HUB_BASE_URL:https://iot-hub.thingsboard.io}" # IoT Hub base URL for fetching published items, resources, etc.
+ connect-timeout-sec: "${IOT_HUB_CONNECT_TIMEOUT_SEC:5}" # timeout in seconds to connect to IoT Hub server.
+ read-timeout-sec: "${IOT_HUB_READ_TIMEOUT_SEC:10}" # timeout in seconds to read from IoT Hub server.
+ max-file-data-size-bytes: "${IOT_HUB_MAX_FILE_DATA_SIZE_BYTES:104857600}" # maximum size in bytes of a file-data payload fetched from IoT Hub. Requests advertising a larger Content-Length, or streams that exceed this size, are rejected to avoid out-of-memory issues on the platform side.
+ max-uncompressed-archive-bytes: "${IOT_HUB_MAX_UNCOMPRESSED_ARCHIVE_BYTES:209715200}" # maximum cumulative uncompressed size in bytes for a solution template archive. Extraction aborts past this threshold to mitigate zip-bomb attacks.
+ max-uncompressed-entry-bytes: "${IOT_HUB_MAX_UNCOMPRESSED_ENTRY_BYTES:52428800}" # maximum uncompressed size in bytes for any single entry inside a solution template archive.
+ max-archive-entry-count: "${IOT_HUB_MAX_ARCHIVE_ENTRY_COUNT:10000}" # maximum number of entries allowed inside a solution template archive.
+ max-install-timeout-ms: "${IOT_HUB_MAX_INSTALL_TIMEOUT_MS:60000}" # upper clamp in milliseconds for the post-install wait declared in a solution template's solution.json. Prevents a malicious or misconfigured template from pinning an HTTP worker thread.
diff --git a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java
index 31da96b7b4..50707e4c38 100644
--- a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java
+++ b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java
@@ -100,7 +100,7 @@ public class AlarmCommentApiClientTest extends AbstractApiClientTest {
.filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId))
.findFirst()
.get();
- assertEquals("User " + clientTenantAdmin.getEmail() + " deleted his comment", deletedComment.getComment().get("text").asText());
+ assertEquals("Comment was deleted by user " + clientTenantAdmin.getEmail(), deletedComment.getComment().get("text").asText());
}
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
index 055e1e19a0..54a6f30213 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
@@ -229,6 +229,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org";
+ protected static final String SECOND_CUSTOMER_USER_EMAIL = "testsecondcustomer@thingsboard.org";
private static final String CUSTOMER_USER_PASSWORD = "customer";
protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org";
@@ -268,6 +269,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected CustomerId differentTenantCustomerId;
protected UserId customerUserId;
+ protected UserId secondCustomerUserId;
protected UserId differentCustomerUserId;
protected UserId differentTenantCustomerUserId;
@@ -393,9 +395,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
customerUser.setCustomerId(savedCustomer.getId());
customerUser.setEmail(CUSTOMER_USER_EMAIL);
- customerUser = createUserAndLogin(customerUser, CUSTOMER_USER_PASSWORD);
+ customerUser = createUserAndActivate(customerUser, CUSTOMER_USER_PASSWORD);
customerUserId = customerUser.getId();
+ User secondCustomerUser = new User();
+ secondCustomerUser.setAuthority(Authority.CUSTOMER_USER);
+ secondCustomerUser.setTenantId(tenantId);
+ secondCustomerUser.setCustomerId(customerId);
+ secondCustomerUser.setEmail(SECOND_CUSTOMER_USER_EMAIL);
+ secondCustomerUser = createUserAndActivate(secondCustomerUser, CUSTOMER_USER_PASSWORD);
+ secondCustomerUserId = secondCustomerUser.getId();
+
resetTokens();
log.debug("Executed web test setup");
@@ -494,6 +504,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD);
}
+ protected void loginSecondCustomerUser() throws Exception {
+ login(SECOND_CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD);
+ }
+
protected void loginUser(String userName, String password) throws Exception {
login(userName, password);
}
@@ -608,6 +622,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return savedUser;
}
+ protected User createUserAndActivate(User user, String password) throws Exception {
+ User savedUser = doPost("/api/user", user, User.class);
+ JsonNode activateRequest = getActivateRequest(password);
+ doPost("/api/noauth/activate", activateRequest).andExpect(status().isOk());
+ return savedUser;
+ }
+
protected User createUser(User user, String password) throws Exception {
User savedUser = doPost("/api/user", user, User.class);
JsonNode activateRequest = getActivateRequest(password);
diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
index 3bb5fd5647..99e98e6546 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
@@ -161,6 +161,25 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment);
}
+ @Test
+ public void testEditOthersAlarmCommentIsProhibited() throws Exception {
+ loginCustomerUser();
+ AlarmComment alarmComment = createAlarmComment(alarm.getId());
+
+ JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Second customer rewrite"));
+ alarmComment.setComment(newComment);
+
+ loginSecondCustomerUser();
+ doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment)
+ .andExpect(status().isForbidden())
+ .andExpect(statusReason(containsString("User is not allowed to edit other user's comment")));
+
+ loginTenantAdmin();
+ doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment)
+ .andExpect(status().isForbidden())
+ .andExpect(statusReason(containsString("User is not allowed to edit other user's comment")));
+ }
+
@Test
public void testUpdateAlarmViaDifferentTenant() throws Exception {
loginTenantAdmin();
@@ -218,6 +237,32 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment);
}
+ @Test
+ public void testDeleteOthersAlarmCommentIsAllowedForAuthorOrTenantAdmin() throws Exception {
+ loginCustomerUser();
+ AlarmComment alarmComment = createAlarmComment(alarm.getId());
+
+ loginSecondCustomerUser();
+ Mockito.reset(tbClusterService, auditLogService);
+
+ doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
+ .andExpect(status().isForbidden())
+ .andExpect(statusReason(containsString("User is not allowed to delete other user's comment")));
+
+ loginTenantAdmin();
+ doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
+ .andExpect(status().isOk());
+ AlarmComment expectedAlarmComment = AlarmComment.builder()
+ .alarmId(alarm.getId())
+ .type(AlarmCommentType.SYSTEM)
+ .comment(JacksonUtil.newObjectNode()
+ .put("text", String.format(COMMENT_DELETED.getText(), TENANT_ADMIN_EMAIL))
+ .put("subtype", COMMENT_DELETED.name())
+ .put("userName", TENANT_ADMIN_EMAIL))
+ .build();
+ testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment);
+ }
+
@Test
public void testDeleteAlarmViaTenant() throws Exception {
loginTenantAdmin();
@@ -237,7 +282,7 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
assertThat(systemComment.getId()).isEqualTo(alarmComment.getId());
assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM);
- assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment",
+ assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("Comment was deleted by user %s",
TENANT_ADMIN_EMAIL));
AlarmComment expectedAlarmComment = AlarmComment.builder()
diff --git a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
index ee2aefc81a..d1621b29ec 100644
--- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
@@ -410,7 +410,7 @@ public class HomePageApiTest extends AbstractControllerTest {
Assert.assertEquals(1, usageInfo.getCustomers());
Assert.assertEquals(configuration.getMaxCustomers(), usageInfo.getMaxCustomers());
- Assert.assertEquals(2, usageInfo.getUsers());
+ Assert.assertEquals(3, usageInfo.getUsers());
Assert.assertEquals(configuration.getMaxUsers(), usageInfo.getMaxUsers());
Assert.assertEquals(DEFAULT_DASHBOARDS_COUNT, usageInfo.getDashboards());
@@ -476,7 +476,7 @@ public class HomePageApiTest extends AbstractControllerTest {
}
usageInfo = doGet("/api/usage", UsageInfo.class);
- Assert.assertEquals(users.size() + 2, usageInfo.getUsers());
+ Assert.assertEquals(users.size() + 3, usageInfo.getUsers());
List dashboards = new ArrayList<>();
for (int i = 0; i < 97; i++) {
diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
index 957b3721e0..908981b0ca 100644
--- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
@@ -717,6 +717,7 @@ public class UserControllerTest extends AbstractControllerTest {
String email = "testEmail1";
List expectedCustomerUserIds = new ArrayList<>();
expectedCustomerUserIds.add(customerUserId);
+ expectedCustomerUserIds.add(secondCustomerUserId);
for (int i = 0; i < 45; i++) {
User customerUser = createCustomerUser(customerId);
customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org");
diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
index fb9807a2a8..c2f7c39ce5 100644
--- a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
@@ -15,20 +15,29 @@
*/
package org.thingsboard.server.service.ai;
-import com.google.cloud.vertexai.api.GenerationConfig;
+import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequestParameters;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
-import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.SsrfProtectionValidator;
+import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@@ -53,18 +62,280 @@ class Langchain4jChatModelConfigurerImplTest {
private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
- @BeforeEach
- void enableSsrfProtection() {
- SsrfProtectionValidator.setEnabled(true);
- }
-
@AfterEach
- void disableSsrfProtection() {
+ void resetSsrfProtection() {
SsrfProtectionValidator.setEnabled(false);
}
+ // ============================== Configuration correctness (one per provider) ==============================
+ // For each provider we feed a fully populated config and assert that the returned ChatModel carries the same
+ // values, using only the public ChatModel surface (provider() and defaultRequestParameters()) — no reflection.
+
+ @Test
+ void shouldConfigureOpenAiModel_whenGivenOpenAiConfig() {
+ // GIVEN
+ var config = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("https://api.openai.com/v1")
+ .apiKey("test-key")
+ .build())
+ .modelId("gpt-4o")
+ .temperature(0.7)
+ .topP(0.9)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.OPEN_AI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gpt-4o");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.frequencyPenalty()).isEqualTo(0.5);
+ assertThat(params.presencePenalty()).isEqualTo(0.25);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureAzureOpenAiModel_whenGivenAzureOpenAiConfig() {
+ // GIVEN
+ var config = AzureOpenAiChatModelConfig.builder()
+ .providerConfig(new AzureOpenAiProviderConfig(
+ "https://my-resource.openai.azure.com/", "2024-05-01-preview", "test-key"))
+ .modelId("gpt-4o")
+ .temperature(0.7)
+ .topP(0.9)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.AZURE_OPEN_AI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gpt-4o"); // deployment name maps to modelName
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.frequencyPenalty()).isEqualTo(0.5);
+ assertThat(params.presencePenalty()).isEqualTo(0.25);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureGoogleAiGeminiModel_whenGivenGoogleAiGeminiConfig() {
+ // GIVEN
+ var config = GoogleAiGeminiChatModelConfig.builder()
+ .providerConfig(new GoogleAiGeminiProviderConfig("test-key"))
+ .modelId("gemini-2.5-flash")
+ .temperature(0.7)
+ .topP(0.9)
+ .topK(40)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gemini-2.5-flash");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.topK()).isEqualTo(40);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureGoogleVertexAiGeminiModel_whenGivenGoogleVertexAiGeminiConfig() {
+ // GIVEN
+ var config = GoogleVertexAiGeminiChatModelConfig.builder()
+ .providerConfig(new GoogleVertexAiGeminiProviderConfig(
+ "key.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY))
+ .modelId("gemini-2.5-flash")
+ .temperature(0.7)
+ .topP(0.9)
+ .topK(40)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gemini-2.5-flash");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.topK()).isEqualTo(40);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureMistralAiModel_whenGivenMistralAiConfig() {
+ // GIVEN
+ var config = MistralAiChatModelConfig.builder()
+ .providerConfig(new MistralAiProviderConfig("test-key"))
+ .modelId("mistral-large-latest")
+ .temperature(0.7)
+ .topP(0.9)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.MISTRAL_AI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("mistral-large-latest");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.frequencyPenalty()).isEqualTo(0.5);
+ assertThat(params.presencePenalty()).isEqualTo(0.25);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureAnthropicModel_whenGivenAnthropicConfig() {
+ // GIVEN
+ var config = AnthropicChatModelConfig.builder()
+ .providerConfig(new AnthropicProviderConfig("test-key"))
+ .modelId("claude-opus-4-8")
+ .temperature(0.7)
+ .topP(0.9)
+ .topK(40)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.ANTHROPIC);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("claude-opus-4-8");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.topK()).isEqualTo(40);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
@Test
- void configureChatModel_openAi_withPrivateIp_shouldThrow() {
+ void shouldConfigureAmazonBedrockModel_whenGivenAmazonBedrockConfig() {
+ // GIVEN
+ var config = AmazonBedrockChatModelConfig.builder()
+ .providerConfig(new AmazonBedrockProviderConfig(
+ "us-east-1", "test-access-key-id", "test-secret-access-key"))
+ .modelId("anthropic.claude-3-5-sonnet-20240620-v1:0")
+ .temperature(0.7)
+ .topP(0.9)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.AMAZON_BEDROCK);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("anthropic.claude-3-5-sonnet-20240620-v1:0");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureGitHubModelsModel_whenGivenGitHubModelsConfig() {
+ // GIVEN
+ var config = GitHubModelsChatModelConfig.builder()
+ .providerConfig(new GitHubModelsProviderConfig("ghp-test-token"))
+ .modelId("gpt-4o")
+ .temperature(0.7)
+ .topP(0.9)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.GITHUB_MODELS);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gpt-4o");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.frequencyPenalty()).isEqualTo(0.5);
+ assertThat(params.presencePenalty()).isEqualTo(0.25);
+ assertThat(params.maxOutputTokens()).isEqualTo(500); // maxCompletionTokens maps to maxOutputTokens
+ }
+
+ @Test
+ void shouldConfigureOllamaModel_whenGivenOllamaConfig() {
+ // GIVEN
+ var config = OllamaChatModelConfig.builder()
+ .providerConfig(new OllamaProviderConfig(
+ "http://localhost:11434", new OllamaProviderConfig.OllamaAuth.None()))
+ .modelId("llama3")
+ .temperature(0.7)
+ .topP(0.9)
+ .topK(40)
+ .contextLength(4096)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.OLLAMA);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("llama3");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.topK()).isEqualTo(40);
+ assertThat(params.maxOutputTokens()).isEqualTo(500); // numPredict maps to maxOutputTokens
+ }
+
+ // ============================== Base URL SSRF validation ==============================
+ // Providers that accept a user-supplied base URL must reject hosts that resolve to private/loopback addresses
+ // when SSRF protection is enabled.
+
+ @Test
+ void shouldThrow_whenOpenAiBaseUrlIsPrivateIp() {
+ // GIVEN
+ SsrfProtectionValidator.setEnabled(true);
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://172.17.0.1:8080/")
@@ -73,13 +344,16 @@ class Langchain4jChatModelConfigurerImplTest {
.modelId("gpt-4o")
.build();
+ // WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
- void configureChatModel_openAi_withLocalhostUrl_shouldThrow() {
+ void shouldThrow_whenOpenAiBaseUrlIsLocalhost() {
+ // GIVEN
+ SsrfProtectionValidator.setEnabled(true);
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://localhost:22/")
@@ -88,57 +362,42 @@ class Langchain4jChatModelConfigurerImplTest {
.modelId("gpt-4o")
.build();
+ // WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
- void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() {
+ void shouldThrow_whenAzureOpenAiEndpointIsPrivateIp() {
+ // GIVEN
+ SsrfProtectionValidator.setEnabled(true);
var config = AzureOpenAiChatModelConfig.builder()
.providerConfig(new AzureOpenAiProviderConfig(
"http://10.0.0.1:8080/", null, "test-key"))
.modelId("gpt-4o")
.build();
+ // WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
- void configureChatModel_ollama_withPrivateIp_shouldThrow() {
+ void shouldThrow_whenOllamaBaseUrlIsPrivateIp() {
+ // GIVEN
+ SsrfProtectionValidator.setEnabled(true);
var config = OllamaChatModelConfig.builder()
.providerConfig(new OllamaProviderConfig(
"http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None()))
.modelId("llama3")
.build();
+ // WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
- @Test
- void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
- // GIVEN
- var providerConfig = new GoogleVertexAiGeminiProviderConfig(
- "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY
- );
- var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder()
- .providerConfig(providerConfig)
- .modelId("gemini-2.0-flash")
- .frequencyPenalty(0.3)
- .presencePenalty(0.7)
- .build();
-
- // WHEN
- ChatModel chatModel = configurer.configureChatModel(chatModelConfig);
-
- // THEN
- var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig");
- assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f);
- assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f);
- }
-
}
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
index 4454d41e6e..35f637dc09 100644
--- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
@@ -154,7 +154,7 @@ public class SimpleCalculatedFieldStateTest {
Output output = getCalculatedFieldConfig().getOutput();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
- assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49)));
+ assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49L)));
}
@Test
@@ -184,7 +184,7 @@ public class SimpleCalculatedFieldStateTest {
Output output = getCalculatedFieldConfig().getOutput();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
- assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35)));
+ assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35L)));
}
@Test
diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java
new file mode 100644
index 0000000000..39ba71b927
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java
@@ -0,0 +1,299 @@
+/**
+ * Copyright © 2016-2026 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.edge.rpc;
+
+import io.grpc.stub.StreamObserver;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.server.common.data.edge.Edge;
+import org.thingsboard.server.common.data.id.EdgeId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.gen.edge.v1.ResponseMsg;
+import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
+import org.thingsboard.server.queue.TbQueueConsumer;
+import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
+import org.thingsboard.server.queue.discovery.TopicService;
+import org.thingsboard.server.queue.kafka.KafkaAdmin;
+import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
+import org.thingsboard.server.service.edge.EdgeContextComponent;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BooleanSupplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class KafkaEdgeGrpcSessionTest {
+
+ private static final long POLL_INTERVAL_MS = 20L;
+
+ private EdgeContextComponent ctx;
+ private TbCoreQueueFactory tbCoreQueueFactory;
+ private KafkaEdgeGrpcSession session;
+
+ @BeforeEach
+ void setUp() {
+ ctx = mock(EdgeContextComponent.class);
+ TopicService topicService = mock(TopicService.class);
+ tbCoreQueueFactory = mock(TbCoreQueueFactory.class);
+ KafkaAdmin kafkaAdmin = mock(KafkaAdmin.class);
+ @SuppressWarnings("unchecked")
+ StreamObserver outputStream = mock(StreamObserver.class);
+
+ session = new KafkaEdgeGrpcSession(ctx, topicService, tbCoreQueueFactory, kafkaAdmin, outputStream,
+ (edgeId, s) -> {}, (edge, uuid) -> {}, null, 0, 0);
+
+ ReflectionTestUtils.setField(session, "edge", new Edge(new EdgeId(UUID.randomUUID())));
+ ReflectionTestUtils.setField(session, "tenantId", TenantId.fromUUID(UUID.randomUUID()));
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (session != null) {
+ session.destroy();
+ }
+ }
+
+ @Test
+ void readyOnlyWhenConnectedNotSyncingNotHighPriority() {
+ setReadinessFlags(true, false, false);
+ assertThat(isReadyToProcessGeneralEvents())
+ .as("connected, not syncing, no high-priority work -> ready")
+ .isTrue();
+ }
+
+ @Test
+ void notReadyWhenDisconnected() {
+ setReadinessFlags(false, false, false);
+ assertThat(isReadyToProcessGeneralEvents())
+ .as("disconnected -> not ready")
+ .isFalse();
+ }
+
+ @Test
+ void notReadyWhileSyncInProgress() {
+ setReadinessFlags(true, true, false);
+ assertThat(isReadyToProcessGeneralEvents())
+ .as("sync in progress -> not ready (this is the window where events were being dropped)")
+ .isFalse();
+ }
+
+ @Test
+ void notReadyWhileHighPriorityProcessing() {
+ setReadinessFlags(true, false, true);
+ assertThat(isReadyToProcessGeneralEvents())
+ .as("high-priority processing -> not ready")
+ .isFalse();
+ }
+
+ @Test
+ void processEdgeEventsWiresReadinessPredicateIntoConsumerGate() {
+ // processEdgeEvents() builds the consumer lazily; stub just enough of that path.
+ EdgeEventStorageSettings storageSettings = new EdgeEventStorageSettings();
+ storageSettings.setNoRecordsSleepInterval(1000L);
+ when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings);
+
+ @SuppressWarnings("unchecked")
+ TbQueueConsumer> queueConsumer = mock(TbQueueConsumer.class);
+ // Report stopped so the launched consumer loop exits immediately - this test asserts on wiring, not polling.
+ when(queueConsumer.isStopped()).thenReturn(true);
+ when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer);
+
+ // The consumer is only started when the session is ready.
+ setReadinessFlags(true, false, false);
+ session.processEdgeEvents();
+
+ QueueConsumerManager> manager = session.getConsumer();
+ assertThat(manager).as("processEdgeEvents must build the consumer when ready").isNotNull();
+
+ BooleanSupplier readinessCheck = (BooleanSupplier) ReflectionTestUtils.getField(manager, "readinessCheck");
+ assertThat(readinessCheck)
+ .as("the edge consumer must be wired with a readinessCheck (the .readinessCheck(...) builder line)")
+ .isNotNull();
+
+ // It must be the live predicate, not a snapshot: flipping the session's state must flip the gate.
+ assertThat(readinessCheck.getAsBoolean()).as("ready session -> gate open").isTrue();
+ setReadinessFlags(true, true, false);
+ assertThat(readinessCheck.getAsBoolean()).as("sync starts -> gate closes, consumer pauses polling").isFalse();
+ }
+
+ @Test
+ void eventArrivingDuringSyncIsHeldByTheEdgeConsumerUntilSyncCompletes() {
+ EdgeEventStorageSettings storageSettings = new EdgeEventStorageSettings();
+ storageSettings.setNoRecordsSleepInterval(POLL_INTERVAL_MS);
+ when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings);
+
+ RecordingEdgeEventConsumer queueConsumer = new RecordingEdgeEventConsumer();
+ when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer);
+
+ // The consumer is launched only while the session is ready - that is how it starts in production.
+ setReadinessFlags(true, false, false);
+ session.processEdgeEvents();
+
+ // Sync starts: the gate closes. Wait until the loop has actually parked on it (poll count stops advancing)
+ // before enqueuing - otherwise we would race an in-flight poll() and the test would be non-deterministic.
+ setReadinessFlags(true, true, false);
+ awaitParkedOnClosedGate(queueConsumer);
+
+ // An event lands in the edge-event topic during the sync window - exactly the case that used to be dropped.
+ @SuppressWarnings("unchecked")
+ TbProtoQueueMsg event = mock(TbProtoQueueMsg.class);
+ int pollsBeforeEvent = queueConsumer.getPollCount();
+ queueConsumer.enqueue(List.of(event));
+
+ // While sync is in progress the consumer stays parked: it neither polls nor consumes the event,
+ // so the event survives in the queue instead of being read-and-skipped.
+ sleepQuietly(POLL_INTERVAL_MS * 5);
+ assertThat(queueConsumer.getPolledEvents())
+ .as("event must not be polled while sync is in progress (it must stay queued, not be dropped)")
+ .isEmpty();
+ assertThat(queueConsumer.getPollCount())
+ .as("consumer must not poll at all while the gate is closed")
+ .isEqualTo(pollsBeforeEvent);
+
+ // Sync completes: the gate opens and the held event is finally picked up by the consumer.
+ // (We assert at the poll boundary - the actual drop point - since the downlink-send path that
+ // processMsgs drives afterwards is not reachable from a unit test.)
+ setReadinessFlags(true, false, false);
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(() -> assertThat(queueConsumer.getPolledEvents())
+ .as("event held during sync must be picked up once sync completes, not lost")
+ .hasSize(1));
+ }
+
+ private boolean isReadyToProcessGeneralEvents() {
+ return Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(session, "isReadyToProcessGeneralEvents"));
+ }
+
+ private void setReadinessFlags(boolean connected, boolean syncInProgress, boolean highPriorityProcessing) {
+ ReflectionTestUtils.setField(session, "connected", connected);
+ ReflectionTestUtils.setField(session, "syncInProgress", syncInProgress);
+ ReflectionTestUtils.setField(session, "isHighPriorityProcessing", highPriorityProcessing);
+ }
+
+ private static void awaitParkedOnClosedGate(RecordingEdgeEventConsumer consumer) {
+ await().atMost(5, TimeUnit.SECONDS).until(() -> {
+ int before = consumer.getPollCount();
+ // Several poll intervals with no new poll means the loop is parked on the readiness gate rather than
+ // blocked inside poll(), so it is safe to enqueue without racing an in-flight read.
+ sleepQuietly(POLL_INTERVAL_MS * 5);
+ return consumer.getPollCount() == before;
+ });
+ }
+
+ private static void sleepQuietly(long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * Fake edge-event consumer that records what it was actually polled, so a test can prove an event stayed in the
+ * queue while the readiness gate was closed and was only read once it opened.
+ */
+ private static class RecordingEdgeEventConsumer implements TbQueueConsumer> {
+
+ private final Queue>> pending = new ConcurrentLinkedQueue<>();
+ private final List> polledEvents = new CopyOnWriteArrayList<>();
+ private final AtomicInteger pollCount = new AtomicInteger();
+ private volatile boolean stopped;
+
+ void enqueue(List> batch) {
+ pending.add(batch);
+ }
+
+ int getPollCount() {
+ return pollCount.get();
+ }
+
+ List> getPolledEvents() {
+ return polledEvents;
+ }
+
+ @Override
+ public List> poll(long durationInMillis) {
+ pollCount.incrementAndGet();
+ List> batch = pending.poll();
+ if (batch != null) {
+ polledEvents.addAll(batch);
+ return batch;
+ }
+ sleepQuietly(durationInMillis);
+ return Collections.emptyList();
+ }
+
+ @Override
+ public String getTopic() {
+ return "test-edge-event-topic";
+ }
+
+ @Override
+ public void subscribe() {
+ }
+
+ @Override
+ public void subscribe(Set partitions) {
+ }
+
+ @Override
+ public void stop() {
+ stopped = true;
+ }
+
+ @Override
+ public void unsubscribe() {
+ stopped = true;
+ }
+
+ @Override
+ public void commit() {
+ }
+
+ @Override
+ public boolean isStopped() {
+ return stopped;
+ }
+
+ @Override
+ public List getFullTopicNames() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public Set getPartitions() {
+ return Collections.emptySet();
+ }
+
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java
new file mode 100644
index 0000000000..81cc325bdb
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java
@@ -0,0 +1,141 @@
+/**
+ * Copyright © 2016-2026 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.entitiy.queue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.data.id.QueueId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.queue.Queue;
+import org.thingsboard.server.dao.queue.QueueService;
+import org.thingsboard.server.queue.TbQueueAdmin;
+import org.thingsboard.server.queue.discovery.TopicService;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultTbQueueServiceTest {
+
+ @Mock
+ private QueueService queueServiceMock;
+ @Mock
+ private TbClusterService tbClusterServiceMock;
+ @Mock
+ private TbQueueAdmin tbQueueAdminMock;
+
+ private TopicService topicService;
+ private DefaultTbQueueService tbQueueService;
+
+ private final TenantId tenantId = TenantId.SYS_TENANT_ID;
+
+ @BeforeEach
+ public void setUp() {
+ topicService = new TopicService();
+ tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService);
+ }
+
+ private Queue newQueue(int partitions) {
+ Queue queue = new Queue();
+ queue.setTenantId(tenantId);
+ queue.setName("testQueue");
+ queue.setTopic("tb_rule_engine.testQueue");
+ queue.setPartitions(partitions);
+ return queue;
+ }
+
+ @Test
+ public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() {
+ // queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set)
+ ReflectionTestUtils.setField(topicService, "prefix", "thingsboard");
+
+ Queue queue = newQueue(2);
+ when(queueServiceMock.saveQueue(queue)).thenReturn(queue);
+
+ tbQueueService.saveQueue(queue);
+
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
+
+ // All created topics must carry the prefix - this is the fix.
+ assertThat(topicCaptor.getAllValues())
+ .containsExactlyInAnyOrder(
+ "thingsboard.tb_rule_engine.testQueue.0",
+ "thingsboard.tb_rule_engine.testQueue.1");
+ // No unprefixed (orphan-prone) topic must ever be created.
+ assertThat(topicCaptor.getAllValues())
+ .noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0")
+ || topic.equals("tb_rule_engine.testQueue.1"));
+ }
+
+ @Test
+ public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() {
+ // queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved
+ ReflectionTestUtils.setField(topicService, "prefix", "");
+
+ Queue queue = newQueue(2);
+ when(queueServiceMock.saveQueue(queue)).thenReturn(queue);
+
+ tbQueueService.saveQueue(queue);
+
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
+
+ assertThat(topicCaptor.getAllValues())
+ .containsExactlyInAnyOrder(
+ "tb_rule_engine.testQueue.0",
+ "tb_rule_engine.testQueue.1");
+ }
+
+ @Test
+ public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() {
+ ReflectionTestUtils.setField(topicService, "prefix", "thingsboard");
+
+ Queue oldQueue = newQueue(2);
+ oldQueue.setId(new QueueId(UUID.randomUUID()));
+ Queue updatedQueue = newQueue(4);
+ updatedQueue.setId(oldQueue.getId());
+
+ when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue);
+ when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue);
+
+ tbQueueService.saveQueue(updatedQueue);
+
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
+
+ assertThat(topicCaptor.getAllValues())
+ .containsExactlyInAnyOrder(
+ "thingsboard.tb_rule_engine.testQueue.2",
+ "thingsboard.tb_rule_engine.testQueue.3");
+ verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean());
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsServiceTest.java b/application/src/test/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsServiceTest.java
new file mode 100644
index 0000000000..91c455c292
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsServiceTest.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright © 2016-2026 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.install;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class DefaultDatabaseSchemaSettingsServiceTest {
+
+ @Mock
+ private ProjectInfo projectInfo;
+
+ @Mock
+ private JdbcTemplate jdbcTemplate;
+
+ @InjectMocks
+ private DefaultDatabaseSchemaSettingsService service;
+
+ @Test
+ void updateSchemaVersionWithExplicitVersionEncodesAsLong() {
+ service.updateSchemaVersion("4.2.2.3");
+ verify(jdbcTemplate).execute("UPDATE tb_schema_settings SET schema_version = 4002002003");
+ }
+
+ @Test
+ void updateSchemaVersionWithShortVersionPadsMissingComponents() {
+ service.updateSchemaVersion("4.3");
+ verify(jdbcTemplate).execute("UPDATE tb_schema_settings SET schema_version = 4003000000");
+ }
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationIntegrationTest.java b/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationIntegrationTest.java
new file mode 100644
index 0000000000..b6cf3178fc
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationIntegrationTest.java
@@ -0,0 +1,241 @@
+/**
+ * Copyright © 2016-2026 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.install.lts;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.WidgetTypeId;
+import org.thingsboard.server.common.data.id.WidgetsBundleId;
+import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.dao.widget.WidgetsBundleService;
+import org.thingsboard.server.service.install.InstallScripts;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@DaoSqlTest
+public class LtsMigrationIntegrationTest extends AbstractControllerTest {
+
+ private static final long V_4_2_2_2 = 4_002_002_002L;
+ private static final long V_4_2_2_3 = 4_002_002_003L;
+ private static final String OBSOLETE_ALIAS = "air_quality";
+
+ // Versions whose family is older than the current package family ship SQL-less beans intentionally
+ // (their schema/data changes are reproduced by the current-family migrations), so a missing dir is OK.
+ private static final Set SQL_LESS_ALLOWED = Set.of();
+
+ @Autowired
+ private LtsMigrationService ltsMigrationService;
+ @Autowired
+ private WidgetsBundleService widgetsBundleService;
+ @Autowired
+ private WidgetTypeService widgetTypeService;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+ @Autowired
+ private InstallScripts installScripts;
+ @Autowired
+ private List migrations;
+
+ private Long originalSchemaVersion;
+ private WidgetsBundleId bundleId;
+ private WidgetTypeId widgetTypeId;
+
+ @Before
+ public void setUp() {
+ // The test-context DB (DaoSqlTest) creates tb_schema_settings empty, so ensure the baseline
+ // version row a real installed DB always has exists before driving the migration.
+ originalSchemaVersion = jdbcTemplate.query("SELECT schema_version FROM tb_schema_settings",
+ rs -> rs.next() ? rs.getLong(1) : null);
+ if (originalSchemaVersion == null) {
+ jdbcTemplate.execute("INSERT INTO tb_schema_settings (schema_version, product) VALUES (" + V_4_2_2_2 + ", 'CE')");
+ }
+
+ // Seed an obsolete system widget bundle with one (non-deprecated) widget type linked to it.
+ WidgetsBundle bundle = new WidgetsBundle();
+ bundle.setTenantId(TenantId.SYS_TENANT_ID);
+ bundle.setAlias(OBSOLETE_ALIAS);
+ bundle.setTitle("Air quality");
+ bundleId = widgetsBundleService.saveWidgetsBundle(bundle).getId();
+
+ WidgetTypeDetails type = new WidgetTypeDetails();
+ type.setTenantId(TenantId.SYS_TENANT_ID);
+ type.setFqn("air_quality_sample_" + UUID.randomUUID());
+ type.setName("Air quality sample");
+ type.setDescriptor(JacksonUtil.fromString("{ \"type\": \"latest\" }", JsonNode.class));
+ WidgetTypeDetails saved = widgetTypeService.saveWidgetType(type);
+ widgetTypeId = saved.getId();
+ widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, bundleId, List.of(saved.getFqn()));
+
+ // Pretend the DB is at 4.2.2.2 so the 4.2.2.3 migration is in range (4.2.2.2, 4.2.2.3].
+ jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + V_4_2_2_2);
+ }
+
+ @After
+ public void tearDown() {
+ // Restore the schema version (or drop the row we seeded) and clean up whatever the migration left behind.
+ if (originalSchemaVersion != null) {
+ jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + originalSchemaVersion);
+ } else {
+ jdbcTemplate.execute("DELETE FROM tb_schema_settings");
+ }
+ if (widgetTypeId != null && widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId) != null) {
+ widgetTypeService.deleteWidgetType(TenantId.SYS_TENANT_ID, widgetTypeId);
+ }
+ WidgetsBundle bundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS);
+ if (bundle != null) {
+ widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
+ }
+ }
+
+ @Test
+ public void appliesSqlDeprecatesTypesDeletesBundleAndRecordsVersion() {
+ ltsMigrationService.applyMigrations("4.2.2.2", "4.2.2.3");
+
+ // 1. The version's SQL ran: iot_hub_installed_item table now exists.
+ assertTrue(tableExists("iot_hub_installed_item"));
+ // 2. The version was recorded.
+ assertEquals(Long.valueOf(V_4_2_2_3),
+ jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
+ // 3. The obsolete bundle entity was deleted.
+ assertNull(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS));
+ // 4. The widget type was KEPT and marked deprecated.
+ WidgetTypeDetails type = widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId);
+ assertNotNull(type);
+ assertTrue(type.isDeprecated());
+ }
+
+ @Test
+ public void reRunFromCurrentVersionIsNoOp() {
+ ltsMigrationService.applyMigrations("4.2.2.2", "4.2.2.3");
+ // Re-running from the now-current version selects an empty range — nothing changes, nothing throws.
+ ltsMigrationService.applyMigrations("4.2.2.3", "4.2.2.3");
+
+ assertEquals(Long.valueOf(V_4_2_2_3),
+ jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
+ assertNull(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS));
+ assertTrue(widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId).isDeprecated());
+ }
+
+ @Test
+ public void offlinePathRunsSchemaThenDataButRecordsNoVersion() {
+ // Drive the offline major-upgrade path over the real supported range against the real DB.
+ ltsMigrationService.runSchemaMigrations("4.2.2.2", "4.2.2.3");
+ // (a) the schema effects landed: the table the 4.2.2.3 SQL creates now exists.
+ assertTrue(tableExists("iot_hub_installed_item"));
+ // (c) the offline schema phase records NO schema version (unlike applyMigrations).
+ assertEquals(Long.valueOf(V_4_2_2_2),
+ jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
+
+ ltsMigrationService.runDataMigrations("4.2.2.2", "4.2.2.3");
+ // (b) the data apply() ran: the obsolete bundle was deleted and its type marked deprecated.
+ assertNull(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS));
+ WidgetTypeDetails type = widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId);
+ assertNotNull(type);
+ assertTrue(type.isDeprecated());
+ // (c) the offline data phase also records NO schema version.
+ assertEquals(Long.valueOf(V_4_2_2_2),
+ jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
+ }
+
+ @Test
+ public void appliesSchemaForV4312AddsCalculatedFieldAdditionalInfo() {
+ // The test-context schema already ships calculated_field.additional_info, so drop it first to prove the
+ // 4.3.1.2 schema_update.sql (ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info) re-adds it.
+ jdbcTemplate.execute("ALTER TABLE calculated_field DROP COLUMN IF EXISTS additional_info");
+ assertFalse(columnExists("calculated_field", "additional_info"));
+
+ // Drive the runner over a range whose target (4.3.1.2) selects only the 4.3.1.2 migration.
+ ltsMigrationService.applyMigrations("4.3.1.1", "4.3.1.2");
+
+ // The 4.3.1.2 schema SQL ran: the column exists again.
+ assertTrue(columnExists("calculated_field", "additional_info"));
+ }
+
+ @Test
+ public void migrationDirectoriesAndBeansStayInSyncBothWays() {
+ Path ltsDir = Paths.get(installScripts.getDataDir(), "upgrade", "lts");
+ Set dirVersions = listDirVersions(ltsDir);
+ Set beanVersions = migrations.stream().map(LtsMigration::getVersion).collect(Collectors.toSet());
+
+ // Every on-disk migration directory must have a registered bean with the same version.
+ // Otherwise select() (which iterates beans, not dirs) silently skips the SQL dir.
+ Set dirsWithoutBean = dirVersions.stream()
+ .filter(v -> !beanVersions.contains(v))
+ .collect(Collectors.toSet());
+ assertTrue("Migration directories without a registered LtsMigration bean: " + dirsWithoutBean,
+ dirsWithoutBean.isEmpty());
+
+ // Every registered bean must have a matching directory, unless it is explicitly allowed to be SQL-less.
+ // Otherwise a typo'd dir name silently runs no SQL for that bean.
+ Set beansWithoutDir = beanVersions.stream()
+ .filter(v -> !dirVersions.contains(v))
+ .filter(v -> !SQL_LESS_ALLOWED.contains(v))
+ .collect(Collectors.toSet());
+ assertTrue("Registered LtsMigration beans without a matching directory (and not SQL-less allowed): " + beansWithoutDir,
+ beansWithoutDir.isEmpty());
+ }
+
+ private Set listDirVersions(Path ltsDir) {
+ if (!Files.isDirectory(ltsDir)) {
+ return Set.of();
+ }
+ try (Stream entries = Files.list(ltsDir)) {
+ return entries.filter(Files::isDirectory)
+ .map(p -> p.getFileName().toString())
+ .collect(Collectors.toSet());
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to list LTS migration directories: " + ltsDir, e);
+ }
+ }
+
+ private boolean tableExists(String table) {
+ Boolean exists = jdbcTemplate.queryForObject(
+ "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = ?)", Boolean.class, table);
+ return Boolean.TRUE.equals(exists);
+ }
+
+ private boolean columnExists(String table, String column) {
+ Boolean exists = jdbcTemplate.queryForObject(
+ "SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?)",
+ Boolean.class, table, column);
+ return Boolean.TRUE.equals(exists);
+ }
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java b/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java
new file mode 100644
index 0000000000..0ff8e64260
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java
@@ -0,0 +1,248 @@
+/**
+ * Copyright © 2016-2026 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.install.lts;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.SimpleTransactionStatus;
+import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
+import org.thingsboard.server.service.install.InstallScripts;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LtsMigrationServiceTest {
+
+ private JdbcTemplate jdbcTemplate;
+ private InstallScripts installScripts;
+ private DatabaseSchemaSettingsService schemaSettingsService;
+ private PlatformTransactionManager txManager;
+
+ @TempDir
+ Path dataDir;
+
+ @BeforeEach
+ void setUp() {
+ jdbcTemplate = Mockito.mock(JdbcTemplate.class);
+ installScripts = Mockito.mock(InstallScripts.class);
+ schemaSettingsService = Mockito.mock(DatabaseSchemaSettingsService.class);
+ txManager = Mockito.mock(PlatformTransactionManager.class);
+ when(txManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+ }
+
+ private void writeSql(String version, String sql) throws Exception {
+ Path dir = dataDir.resolve("upgrade").resolve("lts").resolve(version);
+ Files.createDirectories(dir);
+ Files.writeString(dir.resolve("schema_update.sql"), sql);
+ }
+
+ /** Records which apply() hooks fired, in order. */
+ private LtsMigration migration(String version, List applied) {
+ return new LtsMigration() {
+ @Override public String getVersion() { return version; }
+ @Override public void apply() { applied.add(version); }
+ };
+ }
+
+ private LtsMigrationService service(List migrations) {
+ return new LtsMigrationService(jdbcTemplate, installScripts, schemaSettingsService, txManager, migrations);
+ }
+
+ @Test
+ void selectsOnlyInRangeMigrationsInAscendingOrder() throws Exception {
+ List applied = new ArrayList<>();
+ writeSql("4.2.2.3", "SELECT 1;");
+ writeSql("4.2.2.4", "SELECT 2;");
+ // intentionally unsorted input; service must sort ascending
+ LtsMigrationService service = service(List.of(
+ migration("4.2.2.4", applied),
+ migration("4.2.2.2", applied),
+ migration("4.2.2.3", applied)));
+
+ service.applyMigrations("4.2.2.2", "4.2.2.3");
+
+ // only 4.2.2.3 is in (4.2.2.2, 4.2.2.3]
+ assertEquals(List.of("4.2.2.3"), applied);
+ verify(jdbcTemplate).execute("SELECT 1;");
+ verify(jdbcTemplate, never()).execute("SELECT 2;");
+ verify(schemaSettingsService).updateSchemaVersion("4.2.2.3");
+ }
+
+ @Test
+ void appliesAllInRangeAndRecordsEachVersion() throws Exception {
+ List applied = new ArrayList<>();
+ writeSql("4.2.2.3", "SELECT 1;");
+ writeSql("4.2.2.4", "SELECT 2;");
+ LtsMigrationService service = service(List.of(
+ migration("4.2.2.3", applied), migration("4.2.2.4", applied)));
+
+ service.applyMigrations("4.2.2.2", "4.2.2.4");
+
+ assertEquals(List.of("4.2.2.3", "4.2.2.4"), applied);
+ verify(jdbcTemplate).execute("SELECT 1;");
+ verify(jdbcTemplate).execute("SELECT 2;");
+ verify(schemaSettingsService).updateSchemaVersion("4.2.2.3");
+ verify(schemaSettingsService).updateSchemaVersion("4.2.2.4");
+ }
+
+ @Test
+ void skipsMigrationsOutsideTargetFamilyOnCrossFamilyUpgrade() {
+ List applied = new ArrayList<>();
+ // 4.2.2.3 is carried onto a 4.3 branch by the release-merge cascade but must stay dormant:
+ // a cross-family 4.2 -> 4.3 upgrade is handled entirely by the 4.3-family migrations.
+ LtsMigrationService service = service(List.of(
+ migration("4.2.2.3", applied),
+ migration("4.3.1.2", applied),
+ migration("4.3.1.3", applied)));
+
+ service.runDataMigrations("4.2.2.2", "4.3.1.3");
+
+ assertEquals(List.of("4.3.1.2", "4.3.1.3"), applied);
+ }
+
+ @Test
+ void applyMigrationsSkipsMigrationsOutsideTargetFamilyOnCrossFamilyUpgrade() {
+ List applied = new ArrayList<>();
+ // The dormant 4.2.2.3 bean must not apply or record on a cross-family 4.2 -> 4.3 upgrade.
+ LtsMigrationService service = service(List.of(
+ migration("4.2.2.3", applied),
+ migration("4.3.1.2", applied),
+ migration("4.3.1.3", applied)));
+
+ service.applyMigrations("4.2.2.2", "4.3.1.3");
+
+ assertEquals(List.of("4.3.1.2", "4.3.1.3"), applied);
+ verify(schemaSettingsService, never()).updateSchemaVersion("4.2.2.3");
+ verify(schemaSettingsService).updateSchemaVersion("4.3.1.2");
+ verify(schemaSettingsService).updateSchemaVersion("4.3.1.3");
+ }
+
+ @Test
+ void runSchemaMigrationsSkipsMigrationsOutsideTargetFamilyOnCrossFamilyUpgrade() throws Exception {
+ List applied = new ArrayList<>();
+ writeSql("4.2.2.3", "SELECT 1;");
+ writeSql("4.3.1.2", "SELECT 2;");
+ writeSql("4.3.1.3", "SELECT 3;");
+ LtsMigrationService service = service(List.of(
+ migration("4.2.2.3", applied),
+ migration("4.3.1.2", applied),
+ migration("4.3.1.3", applied)));
+
+ service.runSchemaMigrations("4.2.2.2", "4.3.1.3");
+
+ // The dormant 4.2.2.3 SQL must not run; the 4.3-family SQL must.
+ verify(jdbcTemplate, never()).execute("SELECT 1;");
+ verify(jdbcTemplate).execute("SELECT 2;");
+ verify(jdbcTemplate).execute("SELECT 3;");
+ }
+
+ @Test
+ void isInRangeForTargetFamilyPredicate() {
+ LtsVersion from = LtsVersion.parse("4.3.1.1");
+ LtsVersion to = LtsVersion.parse("4.3.1.3");
+ // same-family in range
+ assertTrue(LtsMigrationService.isInRangeForTargetFamily(LtsVersion.parse("4.3.1.2"), from, to));
+ // older-family bean within the numeric range but wrong family
+ assertFalse(LtsMigrationService.isInRangeForTargetFamily(LtsVersion.parse("4.2.2.3"), from, to));
+ // the target itself (upper boundary, inclusive)
+ assertTrue(LtsMigrationService.isInRangeForTargetFamily(to, from, to));
+ // at from (lower boundary, exclusive)
+ assertFalse(LtsMigrationService.isInRangeForTargetFamily(from, from, to));
+ // below from
+ assertFalse(LtsMigrationService.isInRangeForTargetFamily(LtsVersion.parse("4.3.1.0"), from, to));
+ }
+
+ @Test
+ void reRunAtCurrentVersionIsNoOp() throws Exception {
+ List applied = new ArrayList<>();
+ writeSql("4.2.2.3", "SELECT 1;");
+ LtsMigrationService service = service(List.of(migration("4.2.2.3", applied)));
+
+ service.applyMigrations("4.2.2.3", "4.2.2.3");
+
+ assertEquals(List.of(), applied);
+ verify(jdbcTemplate, never()).execute(anyString());
+ verify(schemaSettingsService, never()).updateSchemaVersion(anyString());
+ }
+
+ @Test
+ void runSchemaMigrationsRunsSqlButNeverAppliesOrRecords() throws Exception {
+ List applied = new ArrayList<>();
+ writeSql("4.2.2.3", "SELECT 1;");
+ LtsMigrationService service = service(List.of(migration("4.2.2.3", applied)));
+
+ service.runSchemaMigrations("4.2.2.2", "4.2.2.3");
+
+ verify(jdbcTemplate).execute("SELECT 1;");
+ assertEquals(List.of(), applied);
+ verify(schemaSettingsService, never()).updateSchemaVersion(anyString());
+ }
+
+ @Test
+ void runDataMigrationsAppliesButNeverRunsSqlOrRecords() throws Exception {
+ List applied = new ArrayList<>();
+ writeSql("4.2.2.3", "SELECT 1;");
+ LtsMigrationService service = service(List.of(migration("4.2.2.3", applied)));
+
+ service.runDataMigrations("4.2.2.2", "4.2.2.3");
+
+ assertEquals(List.of("4.2.2.3"), applied);
+ verify(jdbcTemplate, never()).execute(anyString());
+ verify(schemaSettingsService, never()).updateSchemaVersion(anyString());
+ }
+
+ @Test
+ void migrationWithoutSqlFileStillAppliesAndRecords() {
+ List applied = new ArrayList<>();
+ LtsMigrationService service = service(List.of(migration("4.2.2.3", applied)));
+
+ service.applyMigrations("4.2.2.2", "4.2.2.3");
+
+ assertEquals(List.of("4.2.2.3"), applied);
+ verify(jdbcTemplate, never()).execute(anyString());
+ verify(schemaSettingsService).updateSchemaVersion("4.2.2.3");
+ }
+
+ @Test
+ void failsLoudOnDuplicateVersion() {
+ List applied = new ArrayList<>();
+ assertThrows(IllegalStateException.class, () -> service(List.of(
+ migration("4.2.2.3", applied), migration("4.2.2.3", applied))));
+ }
+
+ @Test
+ void failsLoudOnUnparseableVersion() {
+ List applied = new ArrayList<>();
+ assertThrows(IllegalArgumentException.class, () -> service(List.of(migration("nope", applied))));
+ }
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/install/lts/LtsVersionTest.java b/application/src/test/java/org/thingsboard/server/service/install/lts/LtsVersionTest.java
new file mode 100644
index 0000000000..dc5d3a2fee
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/install/lts/LtsVersionTest.java
@@ -0,0 +1,78 @@
+/**
+ * Copyright © 2016-2026 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.install.lts;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 LtsVersionTest {
+
+ @ParameterizedTest
+ @CsvSource({
+ "4.2.1, 4, 2, 1, 0",
+ "4.2.0, 4, 2, 0, 0",
+ "4.2, 4, 2, 0, 0",
+ "4.0.1.2, 4, 0, 1, 2",
+ "4, 4, 0, 0, 0",
+ "10.20.30.40, 10, 20, 30, 40"
+ })
+ void parsesValidVersions(String input, int major, int minor, int maintenance, int patch) {
+ LtsVersion v = LtsVersion.parse(input);
+ assertEquals(major, v.major());
+ assertEquals(minor, v.minor());
+ assertEquals(maintenance, v.maintenance());
+ assertEquals(patch, v.patch());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"invalid", "a.b.c", "1.2.y.x", "1.x.3", ""})
+ void throwsOnInvalidVersions(String input) {
+ assertThrows(IllegalArgumentException.class, () -> LtsVersion.parse(input));
+ }
+
+ @Test
+ void throwsOnNull() {
+ assertThrows(IllegalArgumentException.class, () -> LtsVersion.parse(null));
+ }
+
+ @Test
+ void comparesByEachComponent() {
+ assertTrue(LtsVersion.parse("4.2.2.3").compareTo(LtsVersion.parse("4.2.2.2")) > 0);
+ assertTrue(LtsVersion.parse("4.2.2.0").compareTo(LtsVersion.parse("4.2.1.9")) > 0);
+ assertTrue(LtsVersion.parse("4.3.0.0").compareTo(LtsVersion.parse("4.2.9.9")) > 0);
+ assertEquals(0, LtsVersion.parse("4.2.2.3").compareTo(LtsVersion.parse("4.2.2.3")));
+ }
+
+ @Test
+ void sameFamilyComparesMajorAndMinorOnly() {
+ assertTrue(LtsVersion.parse("4.2.2.3").sameFamily(LtsVersion.parse("4.2.0.0")));
+ assertFalse(LtsVersion.parse("4.3.0.0").sameFamily(LtsVersion.parse("4.2.9.9")));
+ assertFalse(LtsVersion.parse("5.2.0.0").sameFamily(LtsVersion.parse("4.2.0.0")));
+ }
+
+ @Test
+ void toStringIsFourComponentDotForm() {
+ assertEquals("4.2.2.3", LtsVersion.parse("4.2.2.3").toString());
+ assertEquals("4.2.0.0", LtsVersion.parse("4.2").toString());
+ }
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java b/application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java
new file mode 100644
index 0000000000..083a61c974
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java
@@ -0,0 +1,96 @@
+/**
+ * Copyright © 2016-2026 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.install.lts;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.WidgetTypeId;
+import org.thingsboard.server.common.data.id.WidgetsBundleId;
+import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.dao.widget.WidgetsBundleService;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class V4_2_2_3MigrationTest {
+
+ @Mock
+ private WidgetsBundleService widgetsBundleService;
+
+ @Mock
+ private WidgetTypeService widgetTypeService;
+
+ @InjectMocks
+ private V4_2_2_3Migration migration;
+
+ @Test
+ void versionIs4223() {
+ assertEquals("4.2.2.3", migration.getVersion());
+ }
+
+ @Test
+ void deprecatesTypesThenDeletesBundleEntityOnly() {
+ WidgetsBundle bundle = new WidgetsBundle();
+ bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
+ bundle.setAlias("air_quality");
+
+ WidgetTypeDetails fresh = new WidgetTypeDetails();
+ fresh.setId(new WidgetTypeId(UUID.randomUUID()));
+ fresh.setDeprecated(false);
+ WidgetTypeDetails already = new WidgetTypeDetails();
+ already.setId(new WidgetTypeId(UUID.randomUUID()));
+ already.setDeprecated(true);
+
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "air_quality")).thenReturn(bundle);
+ when(widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId())).thenReturn(List.of(fresh, already));
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "indoor_environment")).thenReturn(null);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "industrial_widgets")).thenReturn(null);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "outdoor_environment")).thenReturn(null);
+
+ migration.apply();
+
+ // only the previously non-deprecated type is re-saved, now deprecated
+ verify(widgetTypeService).saveWidgetType(argThat(WidgetTypeDetails::isDeprecated));
+ // widget types are NOT deleted
+ verify(widgetTypeService, never()).deleteWidgetTypesByBundleId(any(), any());
+ // only the bundle entity is removed
+ verify(widgetsBundleService).deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
+ }
+
+ @Test
+ void absentBundleIsNoOp() {
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(any(), any())).thenReturn(null);
+
+ migration.apply();
+
+ verify(widgetTypeService, never()).saveWidgetType(any());
+ verify(widgetsBundleService, never()).deleteWidgetsBundle(any(), any());
+ }
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/install/lts/V4_3_1_3MigrationTest.java b/application/src/test/java/org/thingsboard/server/service/install/lts/V4_3_1_3MigrationTest.java
new file mode 100644
index 0000000000..8d5f1a0190
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/install/lts/V4_3_1_3MigrationTest.java
@@ -0,0 +1,96 @@
+/**
+ * Copyright © 2016-2026 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.install.lts;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.WidgetTypeId;
+import org.thingsboard.server.common.data.id.WidgetsBundleId;
+import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.dao.widget.WidgetsBundleService;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class V4_3_1_3MigrationTest {
+
+ @Mock
+ private WidgetsBundleService widgetsBundleService;
+
+ @Mock
+ private WidgetTypeService widgetTypeService;
+
+ @InjectMocks
+ private V4_3_1_3Migration migration;
+
+ @Test
+ void versionIs4313() {
+ assertEquals("4.3.1.3", migration.getVersion());
+ }
+
+ @Test
+ void deprecatesTypesThenDeletesBundleEntityOnly() {
+ WidgetsBundle bundle = new WidgetsBundle();
+ bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
+ bundle.setAlias("air_quality");
+
+ WidgetTypeDetails fresh = new WidgetTypeDetails();
+ fresh.setId(new WidgetTypeId(UUID.randomUUID()));
+ fresh.setDeprecated(false);
+ WidgetTypeDetails already = new WidgetTypeDetails();
+ already.setId(new WidgetTypeId(UUID.randomUUID()));
+ already.setDeprecated(true);
+
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "air_quality")).thenReturn(bundle);
+ when(widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId())).thenReturn(List.of(fresh, already));
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "indoor_environment")).thenReturn(null);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "industrial_widgets")).thenReturn(null);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "outdoor_environment")).thenReturn(null);
+
+ migration.apply();
+
+ // only the previously non-deprecated type is re-saved, now deprecated
+ verify(widgetTypeService).saveWidgetType(argThat(WidgetTypeDetails::isDeprecated));
+ // widget types are NOT deleted
+ verify(widgetTypeService, never()).deleteWidgetTypesByBundleId(any(), any());
+ // only the bundle entity is removed
+ verify(widgetsBundleService).deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
+ }
+
+ @Test
+ void absentBundleIsNoOp() {
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(any(), any())).thenReturn(null);
+
+ migration.apply();
+
+ verify(widgetTypeService, never()).saveWidgetType(any());
+ verify(widgetsBundleService, never()).deleteWidgetsBundle(any(), any());
+ }
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java b/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java
new file mode 100644
index 0000000000..c252b58103
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java
@@ -0,0 +1,369 @@
+/**
+ * Copyright © 2016-2026 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.iot_hub;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.id.IotHubInstalledItemId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor;
+import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem;
+import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService;
+import org.thingsboard.server.service.security.model.SecurityUser;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DefaultIotHubServiceTest {
+
+ private final TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ private final SecurityUser user = mock(SecurityUser.class);
+
+ @Mock
+ private IotHubRestClient iotHubRestClient;
+ @Mock
+ private IotHubInstalledItemService iotHubInstalledItemService;
+
+ @Spy
+ @InjectMocks
+ private DefaultIotHubService service;
+
+ private void mockTenant() {
+ when(user.getTenantId()).thenReturn(tenantId);
+ }
+
+ private IotHubInstalledItem installedItem(UUID id) {
+ IotHubInstalledItem item = new IotHubInstalledItem();
+ item.setId(new IotHubInstalledItemId(id));
+ item.setTenantId(tenantId);
+ return item;
+ }
+
+ private InstallPlanEntry willInstall(String versionId, boolean root) {
+ InstallPlanEntry entry = new InstallPlanEntry();
+ entry.setItemId(UUID.randomUUID().toString());
+ entry.setVersionId(versionId);
+ entry.setName(root ? "Root" : "Dep");
+ entry.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
+ entry.setRoot(root);
+ return entry;
+ }
+
+ private ObjectNode version(String id, String itemId, String type, String name, String ver) {
+ ObjectNode node = JacksonUtil.newObjectNode();
+ node.put("id", id);
+ node.put("itemId", itemId);
+ node.put("type", type);
+ node.put("name", name);
+ node.put("version", ver);
+ return node;
+ }
+
+ @Test
+ void resolveInstallPlan_rootOnly_noRelated_marksWillInstall() {
+ mockTenant();
+ String versionId = UUID.randomUUID().toString();
+ String itemId = UUID.randomUUID().toString();
+ when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(version(versionId, itemId, "DASHBOARD", "Root", "1.0"));
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
+
+ InstallPlan plan = service.resolveInstallPlan(user, versionId);
+
+ assertThat(plan.getEntries()).hasSize(1);
+ InstallPlanEntry root = plan.getEntries().get(0);
+ assertThat(root.isRoot()).isTrue();
+ assertThat(root.getStatus()).isEqualTo(InstallPlanEntry.Status.WILL_INSTALL);
+ assertThat(root.getItemId()).isEqualTo(itemId);
+ }
+
+ @Test
+ void resolveInstallPlan_ordersDependenciesBeforeRoot() {
+ mockTenant();
+ String versionId = UUID.randomUUID().toString();
+ String rootItemId = UUID.randomUUID().toString();
+ String relatedItemId = UUID.randomUUID().toString();
+ ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
+ root.putArray("relatedItems").add(relatedItemId);
+ when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
+ when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId))
+ .thenReturn(version(UUID.randomUUID().toString(), relatedItemId, "WIDGET", "Dep", "2.0"));
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
+
+ InstallPlan plan = service.resolveInstallPlan(user, versionId);
+
+ assertThat(plan.getEntries()).hasSize(2);
+ assertThat(plan.getEntries().get(0).getItemId()).isEqualTo(relatedItemId);
+ assertThat(plan.getEntries().get(0).isRoot()).isFalse();
+ assertThat(plan.getEntries().get(1).getItemId()).isEqualTo(rootItemId);
+ assertThat(plan.getEntries().get(1).isRoot()).isTrue();
+ }
+
+ @Test
+ void resolveInstallPlan_missingRelated_recordedAsMissing() {
+ mockTenant();
+ String versionId = UUID.randomUUID().toString();
+ String rootItemId = UUID.randomUUID().toString();
+ String relatedItemId = UUID.randomUUID().toString();
+ ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
+ root.putArray("relatedItems").add(relatedItemId);
+ when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
+ when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenReturn(null);
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
+
+ InstallPlan plan = service.resolveInstallPlan(user, versionId);
+
+ InstallPlanEntry missing = plan.getEntries().get(0);
+ assertThat(missing.getStatus()).isEqualTo(InstallPlanEntry.Status.MISSING);
+ assertThat(missing.getItemId()).isEqualTo(relatedItemId);
+ assertThat(missing.getErrorMessage()).isNotBlank();
+ }
+
+ @Test
+ void resolveInstallPlan_relatedFetchThrows_recordedAsMissing() {
+ mockTenant();
+ String versionId = UUID.randomUUID().toString();
+ String rootItemId = UUID.randomUUID().toString();
+ String relatedItemId = UUID.randomUUID().toString();
+ ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
+ root.putArray("relatedItems").add(relatedItemId);
+ when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
+ when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenThrow(new RuntimeException("boom"));
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
+
+ InstallPlan plan = service.resolveInstallPlan(user, versionId);
+
+ assertThat(plan.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.MISSING);
+ assertThat(plan.getEntries().get(0).getErrorMessage()).contains("boom");
+ }
+
+ @Test
+ void resolveInstallPlan_alreadyInstalledRoot_marksAlreadyInstalled() {
+ mockTenant();
+ String versionId = UUID.randomUUID().toString();
+ UUID rootItemId = UUID.randomUUID();
+ when(iotHubRestClient.getVersionInfo(versionId))
+ .thenReturn(version(versionId, rootItemId.toString(), "WIDGET", "Root", "1.0"));
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of(rootItemId));
+
+ InstallPlan plan = service.resolveInstallPlan(user, versionId);
+
+ assertThat(plan.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED);
+ }
+
+ @Test
+ void resolveInstallPlan_nullVersion_throws() {
+ mockTenant();
+ String versionId = UUID.randomUUID().toString();
+ when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(null);
+
+ assertThatThrownBy(() -> service.resolveInstallPlan(user, versionId))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void installPlan_emptyPlan_returnsFailureWithoutInstalling() {
+ InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of()), null, null);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getErrorMessage()).contains("empty");
+ verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantIdAndItemIdIn(any(), any());
+ }
+
+ @Test
+ void installPlan_missingEntry_collectedAndNotInstalled() {
+ mockTenant();
+ InstallPlanEntry missing = new InstallPlanEntry();
+ missing.setItemId(UUID.randomUUID().toString());
+ missing.setStatus(InstallPlanEntry.Status.MISSING);
+
+ InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(missing)), null, null);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getMissingItemIds()).containsExactly(missing.getItemId());
+ assertThat(result.getRootDescriptor()).isNull();
+ verify(iotHubRestClient, never()).getVersionInfo(anyString());
+ // A plan with nothing to install never hits the DB to check what is already installed.
+ verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantIdAndItemIdIn(any(), any());
+ }
+
+ @Test
+ void installPlan_stalePlan_skipsItemInstalledSinceResolution() {
+ mockTenant();
+ UUID itemId = UUID.randomUUID();
+ InstallPlanEntry entry = new InstallPlanEntry();
+ entry.setItemId(itemId.toString());
+ entry.setVersionId(UUID.randomUUID().toString());
+ entry.setType("WIDGET");
+ entry.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
+ entry.setRoot(true);
+ // The item is reported as already installed at install time, even though the plan says WILL_INSTALL.
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of(itemId));
+
+ InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(entry)), null, null);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED);
+ // No marketplace fetch happened — the duplicate install was avoided.
+ verify(iotHubRestClient, never()).getVersionInfo(anyString());
+ }
+
+ @Test
+ void installPlan_duplicateItemId_installsOnce() throws Exception {
+ mockTenant();
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ UUID itemId = UUID.randomUUID();
+ String firstVersionId = UUID.randomUUID().toString();
+ String secondVersionId = UUID.randomUUID().toString();
+ // Two entries point at the same item — a client-supplied plan is not de-duplicated, so the
+ // cascade itself must not install the same item twice.
+ InstallPlanEntry first = new InstallPlanEntry();
+ first.setItemId(itemId.toString());
+ first.setVersionId(firstVersionId);
+ first.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
+ first.setRoot(true);
+ InstallPlanEntry duplicate = new InstallPlanEntry();
+ duplicate.setItemId(itemId.toString());
+ duplicate.setVersionId(secondVersionId);
+ duplicate.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
+ duplicate.setRoot(false);
+
+ // Nothing is installed yet when the plan is submitted.
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
+ doReturn(installedItem(UUID.randomUUID())).when(service).doInstallVersion(eq(user), eq(firstVersionId), any(), any());
+
+ InstallPlanResult result = service.installPlan(user, new InstallPlan(firstVersionId, List.of(first, duplicate)), null, request);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getEntries()).hasSize(2);
+ assertThat(result.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.WILL_INSTALL);
+ // The duplicate is recognised as freshly installed and skipped.
+ assertThat(result.getEntries().get(1).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED);
+ verify(service).doInstallVersion(eq(user), eq(firstVersionId), any(), any());
+ verify(service, never()).doInstallVersion(eq(user), eq(secondVersionId), any(), any());
+ }
+
+ @Test
+ void installPlan_cascade_installsDependencyBeforeRoot_andRoutesDataToRootOnly() throws Exception {
+ mockTenant();
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ JsonNode data = JacksonUtil.newObjectNode().put("entityId", UUID.randomUUID().toString());
+ String depVersionId = UUID.randomUUID().toString();
+ String rootVersionId = UUID.randomUUID().toString();
+ InstallPlanEntry dep = willInstall(depVersionId, false);
+ InstallPlanEntry root = willInstall(rootVersionId, true);
+
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
+ IotHubInstalledItem depItem = installedItem(UUID.randomUUID());
+ IotHubInstalledItem rootItem = installedItem(UUID.randomUUID());
+ DashboardInstalledItemDescriptor rootDescriptor = new DashboardInstalledItemDescriptor();
+ rootItem.setDescriptor(rootDescriptor);
+ doReturn(depItem).when(service).doInstallVersion(eq(user), eq(depVersionId), any(), any());
+ doReturn(rootItem).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
+
+ InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep, root)), data, request);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.isRolledBack()).isFalse();
+ assertThat(result.getRootDescriptor()).isSameAs(rootDescriptor);
+ assertThat(result.getMissingItemIds()).isEmpty();
+ assertThat(result.getEntries()).hasSize(2);
+
+ // The dependency installs before the root, and only the root receives the user's install data.
+ InOrder inOrder = inOrder(service);
+ inOrder.verify(service).doInstallVersion(eq(user), eq(depVersionId), isNull(), eq(request));
+ inOrder.verify(service).doInstallVersion(eq(user), eq(rootVersionId), eq(data), eq(request));
+ verify(service, never()).deleteInstalledItem(any(), any());
+ }
+
+ @Test
+ void installPlan_dependencyFails_rollsBackInstalledItemsInReverseOrder() throws Exception {
+ mockTenant();
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ String dep1VersionId = UUID.randomUUID().toString();
+ String dep2VersionId = UUID.randomUUID().toString();
+ String rootVersionId = UUID.randomUUID().toString();
+ InstallPlanEntry dep1 = willInstall(dep1VersionId, false);
+ InstallPlanEntry dep2 = willInstall(dep2VersionId, false);
+ InstallPlanEntry root = willInstall(rootVersionId, true);
+
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
+ IotHubInstalledItem dep1Item = installedItem(UUID.randomUUID());
+ IotHubInstalledItem dep2Item = installedItem(UUID.randomUUID());
+ doReturn(dep1Item).when(service).doInstallVersion(eq(user), eq(dep1VersionId), any(), any());
+ doReturn(dep2Item).when(service).doInstallVersion(eq(user), eq(dep2VersionId), any(), any());
+ doThrow(new IllegalStateException("install failed")).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
+ doNothing().when(service).deleteInstalledItem(eq(user), any());
+
+ InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep1, dep2, root)), null, request);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.isRolledBack()).isTrue();
+ assertThat(result.getErrorMessage()).contains("install failed");
+
+ // Successfully installed dependencies are rolled back in reverse install order (dep2 before dep1).
+ InOrder inOrder = inOrder(service);
+ inOrder.verify(service).deleteInstalledItem(user, dep2Item.getId());
+ inOrder.verify(service).deleteInstalledItem(user, dep1Item.getId());
+ }
+
+ @Test
+ void installPlan_rollbackFailure_reportsNotFullyRolledBack() throws Exception {
+ mockTenant();
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ String depVersionId = UUID.randomUUID().toString();
+ String rootVersionId = UUID.randomUUID().toString();
+ InstallPlanEntry dep = willInstall(depVersionId, false);
+ InstallPlanEntry root = willInstall(rootVersionId, true);
+
+ when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
+ IotHubInstalledItem depItem = installedItem(UUID.randomUUID());
+ doReturn(depItem).when(service).doInstallVersion(eq(user), eq(depVersionId), any(), any());
+ doThrow(new IllegalStateException("install failed")).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
+ // Rolling back the one installed dependency itself fails — the result must report a partial rollback.
+ doThrow(new RuntimeException("delete failed")).when(service).deleteInstalledItem(user, depItem.getId());
+
+ InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep, root)), null, request);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.isRolledBack()).isFalse();
+ assertThat(result.getErrorMessage()).contains("install failed");
+ }
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java
index 86b5ae2cf8..53c855aa4d 100644
--- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java
@@ -27,8 +27,11 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.rpc.RpcError;
import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
import org.thingsboard.server.gen.transport.TransportProtos;
+import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService;
import org.thingsboard.server.service.ruleengine.RuleEngineCallService;
import org.thingsboard.server.service.state.DeviceStateService;
@@ -51,6 +54,8 @@ public class DefaultTbCoreConsumerServiceTest {
private TbCoreConsumerStats statsMock;
@Mock
private RuleEngineCallService ruleEngineCallServiceMock;
+ @Mock
+ private TbCoreDeviceRpcService tbCoreDeviceRpcServiceMock;
@Mock
private TbCallback tbCallbackMock;
@@ -638,4 +643,31 @@ public class DefaultTbCoreConsumerServiceTest {
then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock);
}
+ @Test
+ public void givenNotFoundErrorAndNoResponse_whenForwardToCoreRpcService_thenNotFoundAndNullResponseAreRecovered() {
+ // GIVEN
+ ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "tbCoreDeviceRpcService", tbCoreDeviceRpcServiceMock);
+ var requestId = UUID.randomUUID();
+ // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination
+ // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to "").
+ var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder()
+ .setRequestIdMSB(requestId.getMostSignificantBits())
+ .setRequestIdLSB(requestId.getLeastSignificantBits())
+ .setError(RpcError.NOT_FOUND.ordinal())
+ .build();
+ doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToCoreRpcService(proto, tbCallbackMock);
+
+ // WHEN
+ defaultTbCoreConsumerServiceMock.forwardToCoreRpcService(proto, tbCallbackMock);
+
+ // THEN
+ var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class);
+ then(tbCoreDeviceRpcServiceMock).should().processRpcResponseFromRuleEngine(responseCaptor.capture());
+ var response = responseCaptor.getValue();
+ assertThat(response.getId()).isEqualTo(requestId);
+ assertThat(response.getError()).contains(RpcError.NOT_FOUND);
+ assertThat(response.getResponse()).isEmpty();
+ then(tbCallbackMock).should().onSuccess();
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java
new file mode 100644
index 0000000000..1cf41eca0c
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java
@@ -0,0 +1,78 @@
+/**
+ * Copyright © 2016-2026 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.queue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.server.common.data.rpc.RpcError;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
+import org.thingsboard.server.gen.transport.TransportProtos;
+import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg;
+import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.doCallRealMethod;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultTbRuleEngineConsumerServiceTest {
+
+ @Mock
+ private TbRuleEngineDeviceRpcService tbDeviceRpcServiceMock;
+ @Mock
+ private TbCallback tbCallbackMock;
+
+ @Mock
+ private DefaultTbRuleEngineConsumerService defaultTbRuleEngineConsumerServiceMock;
+
+ @Test
+ public void givenNotFoundErrorAndNoResponse_whenHandleFromDeviceRpcResponse_thenNotFoundAndNullResponseAreRecovered() {
+ // GIVEN
+ ReflectionTestUtils.setField(defaultTbRuleEngineConsumerServiceMock, "tbDeviceRpcService", tbDeviceRpcServiceMock);
+ var requestId = UUID.randomUUID();
+ // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination
+ // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to "").
+ var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder()
+ .setRequestIdMSB(requestId.getMostSignificantBits())
+ .setRequestIdLSB(requestId.getLeastSignificantBits())
+ .setError(RpcError.NOT_FOUND.ordinal())
+ .build();
+ var nfMsg = ToRuleEngineNotificationMsg.newBuilder().setFromDeviceRpcResponse(proto).build();
+ var queueMsg = new TbProtoQueueMsg<>(requestId, nfMsg);
+ doCallRealMethod().when(defaultTbRuleEngineConsumerServiceMock).handleNotification(requestId, queueMsg, tbCallbackMock);
+
+ // WHEN
+ defaultTbRuleEngineConsumerServiceMock.handleNotification(requestId, queueMsg, tbCallbackMock);
+
+ // THEN
+ var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class);
+ then(tbDeviceRpcServiceMock).should().processRpcResponseFromDevice(responseCaptor.capture());
+ var response = responseCaptor.getValue();
+ assertThat(response.getId()).isEqualTo(requestId);
+ assertThat(response.getError()).contains(RpcError.NOT_FOUND);
+ assertThat(response.getResponse()).isEmpty();
+ then(tbCallbackMock).should().onSuccess();
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
index f2485dc7d5..9de10e09bb 100644
--- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
+++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
@@ -21,7 +21,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@@ -39,6 +38,7 @@ import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
+import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files;
@@ -56,7 +56,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -91,46 +90,15 @@ public class SystemPatchApplierTest {
@Mock
private ImageService imageService;
+ @Mock
+ private LtsMigrationService ltsMigrationService;
+
@InjectMocks
private SystemPatchApplier reconciler;
@TempDir
Path tempDir;
- @ParameterizedTest(name = "Parse version {0} should return major={1}, minor={2}, patch={3}")
- @CsvSource({
- "4.2.1, 4, 2, 1, 0",
- "4.2.0, 4, 2, 0, 0",
- "4.2, 4, 2, 0, 0",
- "4.0.1.2, 4, 0, 1, 2",
- "4, 4, 0, 0, 0",
- "1.0.5.7, 1, 0, 5, 7",
- "10.20.30.40, 10, 20, 30, 40",
- "0.0.1, 0, 0, 1, 0"
- })
- void testParseVersion(String versionString, int expectedMajor, int expectedMinor, int expectedMaintenance, int expectedPatch) {
- SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", versionString);
-
- assertNotNull(version, "Version should not be null for: " + versionString);
- assertEquals(expectedMajor, version.major(), "Major version mismatch");
- assertEquals(expectedMinor, version.minor(), "Minor version mismatch");
- assertEquals(expectedMaintenance, version.maintenance(), "Maintenance version mismatch");
- assertEquals(expectedPatch, version.patch(), "Patch version mismatch");
- }
-
- @ParameterizedTest(name = "Parse invalid version: {0}")
- @CsvSource({
- "invalid",
- "a.b.c",
- "1.2.y.x",
- "''",
- "1.x.3"
- })
- void testParseInvalidVersion(String invalidVersion) {
- SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", invalidVersion);
- assertNull(version, "Version should be null for invalid input: " + invalidVersion);
- }
-
@Test
void whenLockIsNotAcquired_thenAcquiredIsSuccess() {
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(true);
@@ -449,78 +417,6 @@ public class SystemPatchApplierTest {
verify(widgetTypeService, times(1)).saveWidgetType(any());
}
- // --- isVersionIncreased tests ---
-
- @ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}")
- @MethodSource("provideVersionComparisonTestCases")
- void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion,
- SystemPatchApplier.VersionInfo dbVersion, boolean expected) {
- Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion);
- assertEquals(expected, result, testName);
- }
-
- private static Stream provideVersionComparisonTestCases() {
- return Stream.of(
- // Maintenance digit increases within same LTS family
- Arguments.of("maintenance increased",
- new SystemPatchApplier.VersionInfo(4, 3, 1, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
- Arguments.of("maintenance increased by more than one",
- new SystemPatchApplier.VersionInfo(4, 3, 3, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
-
- // Patch digit increases within same maintenance
- Arguments.of("patch increased",
- new SystemPatchApplier.VersionInfo(4, 3, 0, 1),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
- Arguments.of("patch increased by more than one",
- new SystemPatchApplier.VersionInfo(4, 3, 0, 5),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true),
-
- // Both maintenance and patch increased
- Arguments.of("maintenance and patch both increased",
- new SystemPatchApplier.VersionInfo(4, 3, 1, 1),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
-
- // Maintenance increased, patch value is lower (irrelevant — maintenance wins)
- Arguments.of("maintenance increased, patch is lower",
- new SystemPatchApplier.VersionInfo(4, 3, 2, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true),
-
- // Same version — no increase
- Arguments.of("same version",
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
- Arguments.of("same version with non-zero parts",
- new SystemPatchApplier.VersionInfo(4, 3, 1, 2),
- new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false),
-
- // Decreased versions — no increase
- Arguments.of("maintenance decreased",
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false),
- Arguments.of("patch decreased",
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false),
-
- // Different major — different family, skip
- Arguments.of("different major",
- new SystemPatchApplier.VersionInfo(5, 3, 0, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
- Arguments.of("major decreased",
- new SystemPatchApplier.VersionInfo(3, 3, 0, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
-
- // Different minor — different LTS family, skip
- Arguments.of("minor increased (different LTS family)",
- new SystemPatchApplier.VersionInfo(4, 4, 0, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
- Arguments.of("minor decreased",
- new SystemPatchApplier.VersionInfo(4, 2, 0, 0),
- new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false)
- );
- }
-
// --- isVersionChanged tests ---
@Test
@@ -563,76 +459,22 @@ public class SystemPatchApplierTest {
assertFalse(result);
}
- // --- updateLtsSqlSchema tests ---
-
- @Test
- void whenLtsSqlFileExists_thenExecutesSql() throws Exception {
- Path dataDir = tempDir.resolve("data");
- Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
- Files.createDirectories(ltsDir);
- Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
- when(installScripts.getDataDir()).thenReturn(dataDir.toString());
-
- ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
-
- verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
- }
-
- @Test
- void whenLtsSqlFileDoesNotExist_thenSkips() {
- Path dataDir = tempDir.resolve("data");
- // Don't create the file
- when(installScripts.getDataDir()).thenReturn(dataDir.toString());
-
- ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
-
- verify(jdbcTemplate, never()).execute(anyString());
- }
-
- @Test
- void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception {
- Path dataDir = tempDir.resolve("data");
- Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
- Files.createDirectories(ltsDir);
- String sql = "DO $$ BEGIN\n" +
- " IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" +
- " CREATE TYPE test_type AS ENUM ('A', 'B');\n" +
- " END IF;\n" +
- "END $$;\n" +
- "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);";
- Files.writeString(ltsDir.resolve("schema_update.sql"), sql);
- when(installScripts.getDataDir()).thenReturn(dataDir.toString());
-
- ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
-
- verify(jdbcTemplate).execute(sql);
- }
-
// --- applyPatchIfNeeded flow tests ---
@Test
- void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception {
+ void whenVersionIncreased_thenAppliesMigrationsBeforeViewsAndWidgets() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
- Path dataDir = tempDir.resolve("data");
- Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
- Files.createDirectories(ltsDir);
- Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;");
- when(installScripts.getDataDir()).thenReturn(dataDir.toString());
-
- Path widgetTypesDir = tempDir.resolve("widget_types");
- Files.createDirectories(widgetTypesDir);
- when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(tempDir.resolve("widget_types"));
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
- // LTS SQL was executed
- verify(jdbcTemplate).execute("SELECT 1;");
- // Schema version was updated
+ verify(ltsMigrationService).applyMigrations("4.3.0.0", "4.3.1.0");
verify(schemaSettingsService).updateSchemaVersion();
}
@@ -668,17 +510,16 @@ public class SystemPatchApplierTest {
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
- Path dataDir = tempDir.resolve("data");
- when(installScripts.getDataDir()).thenReturn(dataDir.toString());
-
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
verify(schemaSettingsService).updateSchemaVersion();
+ verify(ltsMigrationService).applyMigrations("4.3.1.0", "4.3.2.0");
}
@Test
diff --git a/common/actor/pom.xml b/common/actor/pom.xml
index 3f490315b9..8af4de2a85 100644
--- a/common/actor/pom.xml
+++ b/common/actor/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2
+ 4.3.1.3
common
org.thingsboard.common
diff --git a/common/cache/pom.xml b/common/cache/pom.xml
index b53f3909e3..45f99c1b5a 100644
--- a/common/cache/pom.xml
+++ b/common/cache/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2
+ 4.3.1.3
common
org.thingsboard.common
diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml
index 0acb6d1817..62995a6460 100644
--- a/common/cluster-api/pom.xml
+++ b/common/cluster-api/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2
+ 4.3.1.3
common
org.thingsboard.common
diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml
index 7e4c38ca42..9f53b63b6e 100644
--- a/common/coap-server/pom.xml
+++ b/common/coap-server/pom.xml
@@ -22,7 +22,7 @@
4.0.0
org.thingsboard
- 4.3.1.2
+ 4.3.1.3
common
org.thingsboard.common
diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
index 3b7248ee72..8ffa489f7b 100644
--- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
+++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
@@ -85,7 +85,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial
dtlsSessionsExecutor.shutdownNow();
}
log.info("Stopping CoAP server!");
- server.destroy();
+ if (server != null) {
+ server.destroy();
+ }
log.info("CoAP server stopped!");
}
@@ -105,27 +107,47 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial
private CoapServer createCoapServer() throws UnknownHostException {
Configuration networkConfig = createNetworkConfiguration();
- server = new CoapServer(networkConfig);
-
- CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder();
- InetAddress addr = InetAddress.getByName(coapServerContext.getHost());
- InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort());
- noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr);
+ try {
+ server = new CoapServer(networkConfig);
+ CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder();
+ InetAddress addr = InetAddress.getByName(coapServerContext.getHost());
+ InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort());
+ noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr);
+
+ noSecCoapEndpointBuilder.setConfiguration(networkConfig);
+ CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build();
+ server.addEndpoint(noSecCoapEndpoint);
+ if (isDtlsEnabled()) {
+ createDtlsEndpoint(networkConfig);
+ dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName());
+ dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS);
+ }
+ Resource root = server.getRoot();
+ TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root);
+ server.setMessageDeliverer(messageDeliverer);
- noSecCoapEndpointBuilder.setConfiguration(networkConfig);
- CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build();
- server.addEndpoint(noSecCoapEndpoint);
- if (isDtlsEnabled()) {
- createDtlsEndpoint(networkConfig);
- dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName());
- dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS);
+ server.start();
+ return server;
+ } catch (RuntimeException | UnknownHostException e) {
+ log.error("Failed to start CoAP server, releasing resources", e);
+ try {
+ if (dtlsSessionsExecutor != null) {
+ dtlsSessionsExecutor.shutdownNow();
+ }
+ if (server != null) {
+ server.destroy();
+ }
+ } catch (Exception suppressed) {
+ e.addSuppressed(suppressed);
+ } finally {
+ server = null;
+ dtlsSessionsExecutor = null;
+ dtlsConnector = null;
+ dtlsCoapEndpoint = null;
+ tbDtlsCertificateVerifier = null;
+ }
+ throw e;
}
- Resource root = server.getRoot();
- TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root);
- server.setMessageDeliverer(messageDeliverer);
-
- server.start();
- return server;
}
private boolean isDtlsEnabled() {
diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java
new file mode 100644
index 0000000000..0c9e9fc29d
--- /dev/null
+++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java
@@ -0,0 +1,145 @@
+/**
+ * Copyright © 2016-2026 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.coapserver;
+
+import org.eclipse.californium.core.CoapServer;
+import org.eclipse.californium.core.network.CoapEndpoint;
+import org.eclipse.californium.core.server.resources.Resource;
+import org.eclipse.californium.scandium.DTLSConnector;
+import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.common.util.ThingsBoardExecutors;
+
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.concurrent.ScheduledExecutorService;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultCoapServerServiceTest {
+
+ private static final String HOST = "127.0.0.1";
+
+ @Mock
+ private CoapServerContext mockCoapServerContext;
+
+ private DefaultCoapServerService service;
+ private DatagramSocket occupiedSocket;
+ private int occupiedPort;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ occupiedSocket = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0));
+ occupiedPort = occupiedSocket.getLocalPort();
+
+ service = new DefaultCoapServerService();
+ ReflectionTestUtils.setField(service, "coapServerContext", mockCoapServerContext);
+
+ when(mockCoapServerContext.getHost()).thenReturn(HOST);
+ when(mockCoapServerContext.getPort()).thenReturn(occupiedPort);
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(null);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ if (occupiedSocket != null && !occupiedSocket.isClosed()) {
+ occupiedSocket.close();
+ }
+ }
+
+ @Test
+ public void whenPlainBindFails_thenInitThrowsAndReleasesCoapServer() {
+ assertThatThrownBy(() -> service.init())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("None of the server endpoints could be started");
+
+ assertThat(ReflectionTestUtils.getField(service, "server")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull();
+ }
+
+ @Test
+ public void whenDtlsEnabledAndStartFails_thenInitShutsDownDtlsExecutorAndReleasesCoapServer() throws Exception {
+ // DTLS enabled: the DTLS endpoint is created and dtlsSessionsExecutor is scheduled before server.start().
+ // This exercises the catch's dtlsSessionsExecutor.shutdownNow() branch, which the plain-bind test does not.
+ TbCoapDtlsSettings mockDtlsSettings = mock(TbCoapDtlsSettings.class);
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
+ when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress(InetAddress.getByName(HOST), occupiedPort + 1));
+ TbCoapDtlsCertificateVerifier mockVerifier = mock(TbCoapDtlsCertificateVerifier.class);
+ when(mockVerifier.getDtlsSessionReportTimeout()).thenReturn(1800000L);
+ when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockVerifier);
+ when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
+
+ ScheduledExecutorService mockExecutor = mock(ScheduledExecutorService.class);
+ Resource mockRoot = mock(Resource.class);
+
+ try (MockedStatic executorsStatic = mockStatic(ThingsBoardExecutors.class);
+ MockedConstruction serverMock = mockConstruction(CoapServer.class, (server, ctx) -> {
+ when(server.getRoot()).thenReturn(mockRoot);
+ doThrow(new IllegalStateException("None of the server endpoints could be started")).when(server).start();
+ });
+ MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class);
+ MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, (builder, ctx) -> {
+ when(builder.setInetSocketAddress(any())).thenReturn(builder);
+ when(builder.setConfiguration(any())).thenReturn(builder);
+ when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
+ when(builder.build()).thenReturn(mock(CoapEndpoint.class));
+ })) {
+
+ executorsStatic.when(() -> ThingsBoardExecutors.newSingleThreadScheduledExecutor(anyString())).thenReturn(mockExecutor);
+
+ assertThatThrownBy(() -> service.init())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("None of the server endpoints could be started");
+
+ // DTLS branch was actually entered and the executor was created...
+ verify(mockDtlsSettings).dtlsConnectorConfig(any());
+ // ...and the cleanup branch shut it down and destroyed the server.
+ verify(mockExecutor).shutdownNow();
+ verify(serverMock.constructed().get(0)).destroy();
+ }
+
+ assertThat(ReflectionTestUtils.getField(service, "server")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull();
+ }
+
+}
diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml
index 69bd94876b..1bcc08a678 100644
--- a/common/dao-api/pom.xml
+++ b/common/dao-api/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2
+ 4.3.1.3
common
org.thingsboard.common
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java
index 36f6fe0feb..597dbd6293 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java
@@ -25,7 +25,11 @@ public interface DeviceConnectivityService {
JsonNode findDevicePublishTelemetryCommands(String baseUrl, Device device) throws URISyntaxException;
+ JsonNode getConnectivityInfo(String baseUrl) throws URISyntaxException;
+
Resource getPemCertFile(String protocol);
Resource createGatewayDockerComposeFile(String baseUrl, Device device) throws URISyntaxException;
+
+ Resource createGatewayDockerComposeFile(String baseUrl, Device device, DockerComposeParams params) throws URISyntaxException;
}
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java
new file mode 100644
index 0000000000..86708910d3
--- /dev/null
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java
@@ -0,0 +1,21 @@
+/**
+ * Copyright © 2016-2026 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.device;
+
+public record DockerComposeParams(boolean includeVersion, String containerName, boolean includePortBindings,
+ boolean includeExtraHosts, boolean includeVolumesBind,
+ boolean includeVolumesDeclaration) {
+}
diff --git a/common/data/pom.xml b/common/data/pom.xml
index aaad442b71..3099d25516 100644
--- a/common/data/pom.xml
+++ b/common/data/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2
+ 4.3.1.3
common
org.thingsboard.common
@@ -84,8 +84,11 @@
test