Browse Source

Feature/device library (#15347)

* Add device-package.models.ts with install wizard types

Defines ConnectivityType, InstallStepType, ENTITY_STEP_TYPES,
stepTypeAliasMap, DevicePackageInfo, DeviceInstallStep, form field
interfaces, and EntityStepProgress types for the device install wizard.

* Add device install wizard dialog component for IoT Hub

Multi-step dialog that provisions TB entities from a device package ZIP:
- Connectivity type selector, instruction/markdown views, dynamic forms
- Entity creation with progress tracking (device profile, device, dashboard, rule chain)
- Variable resolution across steps (form values, entity outputs, positional refs)

* fix(iot-hub): fix device install dialog template and translations

- Use correct tb-markdown inputs ([data] and [usePlainMarkdown])
- Fix translation key format (hyphens, not dots)
- Remove unused ConnectivityType import and resolveImageUrl method
- Auto-advance to first step when single connectivity type
- Use inline data URIs for helpImage directly

* feat(iot-hub): wire device install dialog into all install entry points

Branch DEVICE item type to fetch ZIP and open TbDeviceInstallDialogComponent
instead of the simple install dialog. Applied to:
- Item detail dialog (install button)
- Browse component (card install button)
- Home component (card install button)

* fix(iot-hub): fix build errors in device install dialog

- Fix tb-markdown input: [markdownText] → [data] in done view
- Fix JSZip entry type annotation for strict TS compilation

* fix(iot-hub): reuse existing device profiles and add missing retry translation

- Find device profile by name before creating; reuse if exists
- Add action.retry translation key (was showing raw key)

* feat(iot-hub): add find-or-create for rule chains in device install

Reuse existing rule chains by name (same pattern as device profiles).
Shared entities (device profile, rule chain) are looked up before creation.
Per-install entities (device, dashboard) are always created fresh.

* feat(iot-hub): refactor device install dialog to use mat-stepper

- Replace single-panel view switching with mat-horizontal-stepper
- Group consecutive entity steps into a single "Provisioning" stepper step
- Each SHOW_INSTRUCTION and SHOW_FORM is its own stepper step
- Add 2s minimum display time per entity creation step
- Connectivity selector shown before stepper (not a stepper step)
- Progress step auto-advances to next step after completion
- Stepper shows completed checkmarks for finished steps

* fix(iot-hub): fix stepper navigation by tracking step completion

Linear mat-stepper requires steps to be marked completed before
advancing. Added completed flag to WizardStep, set it when user
clicks Next or when entity steps finish.

* feat(iot-hub): add device package install tracking with entity cleanup

Backend:
- Expand DeviceInstalledItemDescriptor with createdEntityIds and dashboardId
- Add POST /api/iot-hub/device/register endpoint to save installed item
- Implement reverse-order entity deletion for DEVICE uninstall
- Update DEVICE case in updateItemVersion to clean old entities

Frontend:
- Expand DeviceInstalledItemDescriptor TS model
- Add registerDeviceInstall API method
- Wire wizard to collect entity IDs and register after completion
- Enable dashboard navigation for installed device packages

* fix(iot-hub): add type discriminator to device register request body

Jackson requires the 'type' field for @JsonTypeInfo polymorphic
deserialization of DeviceInstalledItemDescriptor.

* feat(iot-hub): show entity type in provisioning progress rows

Each progress row now displays "Device Profile — ESP32 Dev Kit" instead
of just "ESP32 Dev Kit", making it clear what type of entity is being
provisioned.

* fix(iot-hub): suppress global error toast and add Back button on provisioning error

- Add ignoreErrors: true to all entity creation API calls so errors
  are only shown in the dialog, not as global toast notifications
- Add Back button on provisioning error to navigate back to the last
  form step, allowing users to fix parameters (e.g. device name)
- Make form steps editable so stepper allows backward navigation
- Reset progress step state when going back so it re-runs with
  updated form values

* fix(iot-hub): resolve ExpressionChangedAfterItHasBeenCheckedError in device install dialog

Inject ChangeDetectorRef and call detectChanges() after async state
mutations (entity step status changes, stepper auto-advance, ngOnInit
completion). The NG0100 error occurred because async operations
(setTimeout, HTTP calls) mutated component state outside Angular's
change detection cycle.

* fix(iot-hub): fix device register endpoint JSON deserialization

Accept JsonNode and use JacksonUtil.treeToValue() instead of direct
@RequestBody DeviceInstalledItemDescriptor. The @JsonTypeInfo on the
parent interface caused Spring's message converter to fail when
deserializing the concrete class directly.

* fix(iot-hub): fix dashboardId serialization in device register request

DashboardId implements EntityId which uses EntityIdDeserializer requiring
both entityType and id fields. Frontend was sending {"id":"..."} without
entityType, causing "Missing entityType or id!" 400 error.

Fix: include entityType: "DASHBOARD" in the dashboardId payload.
Add deserialization unit test to verify the exact frontend payload format.
pull/15508/head
Andrew Shvayka 6 months ago
committed by GitHub
parent
commit
e05b56b9a4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      application/src/main/java/org/thingsboard/server/controller/IotHubController.java
  2. 89
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  3. 3
      application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java
  4. 7
      common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java
  5. 75
      common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java
  6. 12
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  7. 233
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html
  8. 233
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss
  9. 527
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts
  10. 24
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts
  11. 24
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  12. 2
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts
  13. 29
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts
  14. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts
  15. 134
      ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts
  16. 2
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts
  17. 20
      ui-ngx/src/assets/locale/locale.constant-en_US.json

12
application/src/main/java/org/thingsboard/server/controller/IotHubController.java

@ -31,6 +31,8 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.common.util.JacksonUtil;
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.IotHubInstalledItem;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.PageLink;
@ -64,6 +66,16 @@ public class IotHubController extends BaseController {
return iotHubService.installItemVersion(getCurrentUser(), versionId, data, request); return iotHubService.installItemVersion(getCurrentUser(), versionId, data, request);
} }
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping("/device/register")
@ResponseBody
public InstallItemVersionResult registerDeviceInstall(
@RequestParam String versionId,
@RequestBody JsonNode body) throws ThingsboardException {
DeviceInstalledItemDescriptor descriptor = JacksonUtil.treeToValue(body, DeviceInstalledItemDescriptor.class);
return iotHubService.registerDeviceInstall(getCurrentUser(), versionId, descriptor);
}
@PreAuthorize("hasAuthority('TENANT_ADMIN')") @PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping("/installedItems/{installedItemId}/update/{versionId}") @PostMapping("/installedItems/{installedItemId}/update/{versionId}")
@ResponseBody @ResponseBody

89
application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java

@ -24,8 +24,12 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedField;
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.EntityId;
import org.thingsboard.server.common.data.id.IotHubInstalledItemId; 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.id.TenantId;
import org.thingsboard.server.common.data.iot_hub.CalculatedFieldInstalledItemDescriptor; 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.DashboardInstalledItemDescriptor;
@ -45,12 +49,14 @@ import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.dashboard.DashboardService;
import org.thingsboard.server.dao.device.DeviceProfileService; 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.iot_hub.IotHubInstalledItemService;
import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService; 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.device.profile.TbDeviceProfileService;
import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService; import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService;
import org.thingsboard.server.service.rule.TbRuleChainService; import org.thingsboard.server.service.rule.TbRuleChainService;
@ -58,6 +64,8 @@ import org.thingsboard.server.service.security.model.SecurityUser;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -80,6 +88,8 @@ public class DefaultIotHubService implements IotHubService {
private final DashboardService dashboardService; private final DashboardService dashboardService;
private final CalculatedFieldService calculatedFieldService; private final CalculatedFieldService calculatedFieldService;
private final DeviceProfileService deviceProfileService; private final DeviceProfileService deviceProfileService;
private final DeviceService deviceService;
private final TbDeviceService tbDeviceService;
private final SolutionService solutionService; private final SolutionService solutionService;
@Override @Override
@ -283,7 +293,12 @@ public class DefaultIotHubService implements IotHubService {
case "DASHBOARD" -> updateDashboard(user, tenantId, (DashboardInstalledItemDescriptor) descriptor, fileData); case "DASHBOARD" -> updateDashboard(user, tenantId, (DashboardInstalledItemDescriptor) descriptor, fileData);
case "CALCULATED_FIELD" -> updateCalculatedField(user, tenantId, (CalculatedFieldInstalledItemDescriptor) descriptor, fileData); case "CALCULATED_FIELD" -> updateCalculatedField(user, tenantId, (CalculatedFieldInstalledItemDescriptor) descriptor, fileData);
case "RULE_CHAIN" -> updateRuleChain(tenantId, (RuleChainInstalledItemDescriptor) descriptor, fileData); case "RULE_CHAIN" -> updateRuleChain(tenantId, (RuleChainInstalledItemDescriptor) descriptor, fileData);
case "DEVICE" -> updateDeviceProfile(user, tenantId, fileData); case "DEVICE" -> {
DeviceInstalledItemDescriptor dd = (DeviceInstalledItemDescriptor) descriptor;
deleteDevicePackageEntities(tenantId, dd.getCreatedEntityIds(), user);
dd.setCreatedEntityIds(null);
dd.setDashboardId(null);
}
case "SOLUTION_TEMPLATE" -> { case "SOLUTION_TEMPLATE" -> {
SolutionTemplateInstalledItemDescriptor stDescriptor = (SolutionTemplateInstalledItemDescriptor) descriptor; SolutionTemplateInstalledItemDescriptor stDescriptor = (SolutionTemplateInstalledItemDescriptor) descriptor;
solutionService.deleteSolution(tenantId, stDescriptor.getCreatedEntityIds(), user); solutionService.deleteSolution(tenantId, stDescriptor.getCreatedEntityIds(), user);
@ -474,6 +489,74 @@ public class DefaultIotHubService implements IotHubService {
} }
} }
@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);
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);
iotHubInstalledItemService.save(tenantId, installedItem);
iotHubRestClient.reportVersionInstalled(versionId);
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<EntityId> createdEntityIds, SecurityUser user) {
if (createdEntityIds == null || createdEntityIds.isEmpty()) {
return;
}
List<EntityId> 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 @Override
public void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId) { public void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId) {
TenantId tenantId = user.getTenantId(); TenantId tenantId = user.getTenantId();
@ -505,8 +588,8 @@ public class DefaultIotHubService implements IotHubService {
tbRuleChainService.delete(ruleChain, user); tbRuleChainService.delete(ruleChain, user);
} }
} }
} else if (descriptor instanceof DeviceInstalledItemDescriptor) { } else if (descriptor instanceof DeviceInstalledItemDescriptor dd) {
// no entity to delete for now deleteDevicePackageEntities(tenantId, dd.getCreatedEntityIds(), user);
} else if (descriptor instanceof SolutionTemplateInstalledItemDescriptor st) { } else if (descriptor instanceof SolutionTemplateInstalledItemDescriptor st) {
solutionService.deleteSolution(tenantId, st.getCreatedEntityIds(), user); solutionService.deleteSolution(tenantId, st.getCreatedEntityIds(), user);
} }

3
application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.iot_hub;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.thingsboard.server.common.data.id.IotHubInstalledItemId; 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; import org.thingsboard.server.service.security.model.SecurityUser;
public interface IotHubService { public interface IotHubService {
@ -26,5 +27,7 @@ public interface IotHubService {
UpdateItemVersionResult updateItemVersion(SecurityUser user, IotHubInstalledItemId installedItemId, String versionId, boolean force, 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); void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId);
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java

@ -16,8 +16,15 @@
package org.thingsboard.server.common.data.iot_hub; package org.thingsboard.server.common.data.iot_hub;
import lombok.Data; import lombok.Data;
import org.thingsboard.server.common.data.id.DashboardId;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.List;
@Data @Data
public class DeviceInstalledItemDescriptor implements IotHubInstalledItemDescriptor { public class DeviceInstalledItemDescriptor implements IotHubInstalledItemDescriptor {
private List<EntityId> createdEntityIds;
private DashboardId dashboardId;
} }

75
common/data/src/test/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptorTest.java

@ -0,0 +1,75 @@
/**
* 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.common.data.iot_hub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.DashboardId;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DeviceInstalledItemDescriptorTest {
private static final ObjectMapper mapper = new ObjectMapper();
@Test
void deserializeFromFrontendPayload() throws Exception {
// Exact payload the frontend sends after the fix
String json = """
{
"type": "DEVICE",
"createdEntityIds": [
{"entityType": "DEVICE_PROFILE", "id": "3eb9c330-2e57-11f1-9334-a385172e8e7d"},
{"entityType": "DEVICE", "id": "8a90d5e0-2e5d-11f1-a802-c35a9af2ebde"},
{"entityType": "DASHBOARD", "id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde"}
],
"dashboardId": {"entityType": "DASHBOARD", "id": "8bc229f0-2e5d-11f1-a802-c35a9af2ebde"}
}
""";
// Deserialize the same way the controller does (JsonNode → treeToValue)
JsonNode node = mapper.readTree(json);
DeviceInstalledItemDescriptor descriptor = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class);
assertThat(descriptor).isNotNull();
assertThat(descriptor.getCreatedEntityIds()).hasSize(3);
assertThat(descriptor.getCreatedEntityIds().get(0).getEntityType()).isEqualTo(EntityType.DEVICE_PROFILE);
assertThat(descriptor.getCreatedEntityIds().get(1).getEntityType()).isEqualTo(EntityType.DEVICE);
assertThat(descriptor.getCreatedEntityIds().get(2).getEntityType()).isEqualTo(EntityType.DASHBOARD);
assertThat(descriptor.getDashboardId()).isNotNull();
assertThat(descriptor.getDashboardId().getId()).isEqualTo(UUID.fromString("8bc229f0-2e5d-11f1-a802-c35a9af2ebde"));
}
@Test
void roundTripSerialization() throws Exception {
DeviceInstalledItemDescriptor original = new DeviceInstalledItemDescriptor();
DashboardId dashboardId = new DashboardId(UUID.randomUUID());
original.setDashboardId(dashboardId);
original.setCreatedEntityIds(java.util.List.of(dashboardId));
String json = mapper.writeValueAsString(original);
JsonNode node = mapper.readTree(json);
DeviceInstalledItemDescriptor deserialized = mapper.treeToValue(node, DeviceInstalledItemDescriptor.class);
assertThat(deserialized.getDashboardId().getId()).isEqualTo(original.getDashboardId().getId());
assertThat(deserialized.getCreatedEntityIds()).hasSize(1);
assertThat(deserialized.getCreatedEntityIds().get(0).getId()).isEqualTo(dashboardId.getId());
}
}

12
ui-ngx/src/app/core/http/iot-hub-api.service.ts

@ -112,6 +112,18 @@ export class IotHubApiService {
); );
} }
public registerDeviceInstall(
versionId: string,
descriptor: { type?: string; createdEntityIds: { entityType: string; id: string }[]; dashboardId?: { entityType: string; id: string } },
config?: IotHubRequestConfig
): Observable<InstallItemVersionResult> {
return this.http.post<InstallItemVersionResult>(
`/api/iot-hub/device/register?versionId=${versionId}`,
{ type: 'DEVICE', ...descriptor },
{ params: this.buildParams(config) }
);
}
public installItemVersion(versionId: string, config?: IotHubRequestConfig, data?: any): Observable<InstallItemVersionResult> { public installItemVersion(versionId: string, config?: IotHubRequestConfig, data?: any): Observable<InstallItemVersionResult> {
return this.http.post<InstallItemVersionResult>( return this.http.post<InstallItemVersionResult>(
`/api/iot-hub/versions/${versionId}/install`, `/api/iot-hub/versions/${versionId}/install`,

233
ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html

@ -0,0 +1,233 @@
<!--
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.
-->
@if (loading) {
<div class="tb-device-install-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else {
<div class="tb-device-install-header">
<h2 class="tb-device-install-title">{{ 'iot-hub.device-install-title' | translate:{ name: packageInfo?.name || data.item.name } }}</h2>
@if (selectedConnectivity && wizardStarted) {
<span class="tb-device-install-badge">{{ connectivityLabels.get(selectedConnectivity) }}</span>
}
<button mat-icon-button (click)="cancel()" tabindex="-1">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
@if (!wizardStarted) {
<!-- Connectivity selector -->
<div class="tb-device-install-body">
<div class="tb-device-install-connectivity">
<p>{{ 'iot-hub.device-install-select-connectivity' | translate }}</p>
<div class="tb-connectivity-options">
@for (ct of availableConnectivityTypes; track ct) {
<button mat-stroked-button
class="tb-connectivity-button"
[class.selected]="selectedConnectivity === ct"
(click)="selectConnectivity(ct)">
{{ connectivityLabels.get(ct) || ct }}
</button>
}
</div>
</div>
</div>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary"
[disabled]="!selectedConnectivity"
(click)="confirmConnectivity()">
{{ 'action.next' | translate }}
</button>
</mat-dialog-actions>
} @else {
<!-- Stepper wizard -->
<div class="tb-device-install-stepper-container">
<mat-horizontal-stepper #installStepper
[linear]="true"
labelPosition="bottom">
<ng-template matStepperIcon="edit">
<mat-icon>done</mat-icon>
</ng-template>
@for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) {
<mat-step [editable]="ws.type === 'form'"
[completed]="ws.completed"
[stepControl]="ws.type === 'form' ? ws.formGroup : null">
<ng-template matStepLabel>{{ ws.label }}</ng-template>
<div class="tb-wizard-step-content">
@switch (ws.type) {
@case ('instruction') {
<div class="tb-device-install-instruction">
<tb-markdown [data]="ws.markdown"
[usePlainMarkdown]="true">
</tb-markdown>
</div>
}
@case ('form') {
<div class="tb-device-install-form">
@if (ws.formGroup) {
<form [formGroup]="ws.formGroup">
@for (field of ws.formFields; track field.key) {
@switch (field.type) {
@case ('BOOLEAN') {
<mat-checkbox [formControlName]="field.key">
{{ field.label }}
</mat-checkbox>
}
@case ('SELECT') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<mat-select [formControlName]="field.key">
@for (opt of field.options; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
@if (ws.formGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
</mat-form-field>
}
@case ('PASSWORD') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput [type]="passwordVisible[field.key] ? 'text' : 'password'"
[formControlName]="field.key">
<button mat-icon-button matSuffix type="button"
(click)="passwordVisible[field.key] = !passwordVisible[field.key]">
<mat-icon>{{ passwordVisible[field.key] ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (ws.formGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (ws.formGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
@case ('INTEGER') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput type="number" [formControlName]="field.key">
@if (ws.formGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (ws.formGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
@default {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput [formControlName]="field.key">
@if (ws.formGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (ws.formGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
}
@if (field.helpText) {
<div class="tb-form-field-help">{{ field.helpText }}</div>
}
@if (field.helpImage) {
<img class="tb-form-field-help-image" [src]="field.helpImage" [alt]="field.label">
}
}
</form>
}
</div>
}
@case ('progress') {
<div class="tb-device-install-progress">
@for (ep of ws.entitySteps; track ep.step.name) {
<div class="tb-progress-row">
<div class="tb-progress-icon">
@switch (ep.status) {
@case ('pending') {
<mat-icon class="tb-progress-pending">radio_button_unchecked</mat-icon>
}
@case ('running') {
<mat-spinner diameter="20"></mat-spinner>
}
@case ('success') {
<mat-icon class="tb-progress-success">check_circle</mat-icon>
}
@case ('error') {
<mat-icon class="tb-progress-error">error</mat-icon>
}
}
</div>
<div class="tb-progress-info">
<span class="tb-progress-label">{{ ('iot-hub.device-install-step-type-' + ep.step.type | translate) + ' — ' + (ep.resolvedName || ep.step.name) }}</span>
@if (ep.status === 'error' && ep.errorMessage) {
<span class="tb-progress-error-msg">{{ ep.errorMessage }}</span>
}
</div>
</div>
}
</div>
}
}
</div>
</mat-step>
}
</mat-horizontal-stepper>
</div>
<mat-dialog-actions align="end">
@if (currentWizardStep; as step) {
@switch (step.type) {
@case ('instruction') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="nextStep()">
{{ (isLastWizardStep ? 'action.done' : 'action.next') | translate }}
</button>
}
@case ('form') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="nextStep()">
{{ 'action.next' | translate }}
</button>
}
@case ('progress') {
@if (step.progressError) {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-button (click)="goBackToForm()">{{ 'action.back' | translate }}</button>
<button mat-flat-button color="primary" (click)="retryEntitySteps(step)">
{{ 'action.retry' | translate }}
</button>
} @else if (step.progressDone && isLastWizardStep) {
<button mat-flat-button color="primary" (click)="done()">
{{ 'action.done' | translate }}
</button>
}
}
}
}
</mat-dialog-actions>
}
}

233
ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss

@ -0,0 +1,233 @@
/**
* 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.
*/
:host {
display: flex;
flex-direction: column;
width: 720px;
max-width: 90vw;
max-height: 85vh;
}
.tb-device-install-header {
display: flex;
align-items: center;
padding: 16px 24px;
gap: 12px;
.tb-device-install-title {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.87);
margin: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tb-device-install-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
background: #e3f2fd;
color: #1565c0;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
}
.tb-device-install-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
// Connectivity selector (before stepper)
.tb-device-install-body {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
min-height: 120px;
}
.tb-device-install-connectivity {
display: flex;
flex-direction: column;
gap: 12px;
p {
color: rgba(0, 0, 0, 0.54);
font-size: 14px;
line-height: 20px;
margin: 0;
}
.tb-connectivity-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tb-connectivity-button {
min-width: 100px;
}
.tb-connectivity-button.selected {
background: #305680;
color: #fff;
}
}
// Stepper container
.tb-device-install-stepper-container {
flex: 1;
overflow-y: auto;
min-height: 200px;
::ng-deep {
.mat-horizontal-stepper-header-container {
padding: 0 16px;
}
.mat-horizontal-content-container {
padding: 0 24px 16px;
}
.mat-step-header {
padding: 0 12px;
}
}
}
// Step content wrapper
.tb-wizard-step-content {
min-height: 120px;
}
// Instruction view
.tb-device-install-instruction {
::ng-deep tb-markdown {
display: block;
}
::ng-deep {
h1, h2, h3, h4 {
&:first-child {
margin-top: 0;
}
}
}
}
// Form view
.tb-device-install-form {
display: flex;
flex-direction: column;
gap: 8px;
mat-form-field {
width: 100%;
}
.tb-form-field-help {
color: rgba(0, 0, 0, 0.54);
font-size: 12px;
line-height: 16px;
margin-top: -4px;
margin-bottom: 8px;
}
.tb-form-field-help-image {
max-width: 100%;
margin-top: 4px;
margin-bottom: 8px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.12);
}
}
// Progress view
.tb-device-install-progress {
display: flex;
flex-direction: column;
gap: 12px;
}
.tb-progress-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
}
.tb-progress-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.tb-progress-pending {
color: rgba(0, 0, 0, 0.38);
}
.tb-progress-success {
color: #2e7d32;
}
.tb-progress-error {
color: #c62828;
}
}
.tb-progress-info {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 24px;
justify-content: center;
}
.tb-progress-label {
font-size: 14px;
line-height: 20px;
color: rgba(0, 0, 0, 0.87);
}
.tb-progress-error-msg {
font-size: 12px;
color: #c62828;
font-family: monospace;
white-space: pre-wrap;
word-break: break-word;
}
mat-dialog-actions {
padding: 8px 24px 16px;
}

527
ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts

@ -0,0 +1,527 @@
///
/// 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.
///
import { ChangeDetectorRef, Component, Inject, OnInit, ViewChild } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { MatStepper } from '@angular/material/stepper';
import { firstValueFrom } from 'rxjs';
import { PageLink } from '@shared/models/page/page-link';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { DeviceProfileService } from '@core/http/device-profile.service';
import { DeviceService } from '@core/http/device.service';
import { DashboardService } from '@core/http/dashboard.service';
import { RuleChainService } from '@core/http/rule-chain.service';
import {
connectivityTypeTranslations,
DeviceInstallStep,
DevicePackageInfo,
ENTITY_STEP_TYPES,
EntityStepOutput,
EntityStepProgress,
FormFieldDefinition,
FormFieldType,
InstallStepType,
stepTypeAliasMap
} from '@shared/models/iot-hub/device-package.models';
export interface DeviceInstallDialogData {
item: MpItemVersionView;
zipData: ArrayBuffer;
iotHubApiService: IotHubApiService;
}
export type WizardStepType = 'instruction' | 'form' | 'progress';
export interface WizardStep {
type: WizardStepType;
label: string;
rawSteps: DeviceInstallStep[];
completed: boolean;
// Instruction
markdown?: string;
// Form
formFields?: FormFieldDefinition[];
formGroup?: UntypedFormGroup;
// Progress
entitySteps?: EntityStepProgress[];
progressError?: string;
progressDone?: boolean;
}
const ENTITY_STEP_MIN_DELAY = 2000;
@Component({
selector: 'tb-device-install-dialog',
standalone: false,
templateUrl: './device-install-dialog.component.html',
styleUrls: ['./device-install-dialog.component.scss']
})
export class TbDeviceInstallDialogComponent implements OnInit {
@ViewChild('installStepper', {static: false}) stepper: MatStepper;
loading = true;
packageInfo: DevicePackageInfo;
zipFiles = new Map<string, string>();
// Connectivity
showConnectivitySelector = false;
availableConnectivityTypes: string[] = [];
selectedConnectivity: string | null = null;
connectivityLabels = connectivityTypeTranslations;
// Wizard
wizardSteps: WizardStep[] = [];
wizardStarted = false;
passwordVisible: Record<string, boolean> = {};
// Variable resolution state
formValues: Record<string, any> = {};
entityOutputs = new Map<string, EntityStepOutput>();
positionalOutputs = new Map<number, EntityStepOutput>();
constructor(
@Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData,
private dialogRef: MatDialogRef<TbDeviceInstallDialogComponent>,
private cdr: ChangeDetectorRef,
private deviceProfileService: DeviceProfileService,
private deviceService: DeviceService,
private dashboardService: DashboardService,
private ruleChainService: RuleChainService
) {}
async ngOnInit(): Promise<void> {
try {
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(this.data.zipData);
for (const [path, entry] of Object.entries(zip.files) as [string, any][]) {
if (!entry.dir) {
const content = await entry.async('string');
this.zipFiles.set(path, content);
}
}
this.packageInfo = JSON.parse(this.zipFiles.get('device-info.json'));
this.availableConnectivityTypes = this.packageInfo.connectivityTypes.filter(
ct => this.packageInfo.installSteps[ct]?.length > 0
);
if (this.availableConnectivityTypes.length === 1) {
this.selectedConnectivity = this.availableConnectivityTypes[0];
this.showConnectivitySelector = false;
this.startWizard();
} else {
this.showConnectivitySelector = true;
}
} catch (e) {
console.error('Failed to parse device package ZIP', e);
}
this.loading = false;
this.cdr.detectChanges();
}
// --- Connectivity ---
selectConnectivity(ct: string): void {
this.selectedConnectivity = ct;
}
confirmConnectivity(): void {
if (!this.selectedConnectivity) {
return;
}
this.startWizard();
}
// --- Wizard ---
get isLastWizardStep(): boolean {
return this.stepper && this.stepper.selectedIndex === this.wizardSteps.length - 1;
}
get currentWizardStep(): WizardStep | null {
if (!this.stepper || !this.wizardSteps.length) {
return null;
}
return this.wizardSteps[this.stepper.selectedIndex] ?? null;
}
nextStep(): void {
const step = this.currentWizardStep;
if (!step) {
return;
}
if (step.type === 'form') {
if (step.formGroup?.invalid) {
step.formGroup.markAllAsTouched();
return;
}
Object.assign(this.formValues, step.formGroup.getRawValue());
}
if (this.isLastWizardStep) {
this.done();
return;
}
step.completed = true;
this.stepper.next();
this.onStepActivated();
}
done(): void {
this.dialogRef.close('installed');
}
cancel(): void {
this.dialogRef.close(false);
}
retryEntitySteps(step: WizardStep): void {
step.progressError = null;
this.runEntitySteps(step);
}
goBackToForm(): void {
// Find the last form step before the current progress step
const currentIdx = this.stepper.selectedIndex;
for (let i = currentIdx - 1; i >= 0; i--) {
if (this.wizardSteps[i].type === 'form') {
// Reset the progress step so it re-runs when we come back
const progressStep = this.wizardSteps[currentIdx];
progressStep.entitySteps = null;
progressStep.progressError = null;
progressStep.progressDone = false;
progressStep.completed = false;
// Allow navigation back by making intermediate steps editable
for (let j = i; j < currentIdx; j++) {
this.wizardSteps[j].completed = false;
}
this.stepper.selectedIndex = i;
return;
}
}
}
getPatternErrorMessage(field: FormFieldDefinition): string {
return field.validators?.[0]?.message || 'Invalid format';
}
// --- Variable Resolution ---
resolveVariables(content: string): string {
return content.replace(/\$\{([^}]+)\}/g, (_match, key) => {
if (key in this.formValues) {
return String(this.formValues[key]);
}
const dotIdx = key.indexOf('.');
if (dotIdx > 0) {
const alias = key.substring(0, dotIdx);
const prop = key.substring(dotIdx + 1);
const output = this.entityOutputs.get(alias);
if (output && prop in output) {
return String((output as any)[prop]);
}
const m = alias.match(/^step(\d+)$/);
if (m) {
const pos = this.positionalOutputs.get(parseInt(m[1], 10));
if (pos && prop in pos) {
return String((pos as any)[prop]);
}
}
}
return '${' + key + '}';
});
}
// --- Private ---
private startWizard(): void {
this.formValues = {};
this.entityOutputs.clear();
this.positionalOutputs.clear();
this.passwordVisible = {};
this.buildWizardSteps();
this.wizardStarted = true;
// Activate the first step after the stepper renders
setTimeout(() => this.onStepActivated(), 0);
}
private buildWizardSteps(): void {
const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || [];
this.wizardSteps = [];
let i = 0;
while (i < rawSteps.length) {
const step = rawSteps[i];
if (step.type === InstallStepType.SHOW_INSTRUCTION) {
this.wizardSteps.push({
type: 'instruction',
label: step.name,
rawSteps: [step],
completed: false
});
i++;
} else if (step.type === InstallStepType.SHOW_FORM) {
this.wizardSteps.push({
type: 'form',
label: step.name,
rawSteps: [step],
completed: false
});
i++;
} else if (ENTITY_STEP_TYPES.has(step.type)) {
// Group consecutive entity steps
const group: DeviceInstallStep[] = [];
while (i < rawSteps.length && ENTITY_STEP_TYPES.has(rawSteps[i].type)) {
group.push(rawSteps[i]);
i++;
}
this.wizardSteps.push({
type: 'progress',
label: 'Provisioning',
rawSteps: group,
completed: false
});
} else {
// Skip unsupported steps (CONVERTER, INTEGRATION)
i++;
}
}
// Initialize form groups
for (const ws of this.wizardSteps) {
if (ws.type === 'form') {
this.initFormStep(ws);
}
}
}
private initFormStep(ws: WizardStep): void {
const formJson = this.zipFiles.get(ws.rawSteps[0].file) || '[]';
ws.formFields = JSON.parse(formJson) as FormFieldDefinition[];
const controls: Record<string, UntypedFormControl> = {};
for (const field of ws.formFields) {
const validators = [];
if (field.required) {
validators.push(Validators.required);
}
if (field.validators?.length > 0) {
validators.push(Validators.pattern(field.validators[0].pattern));
}
const initialValue = field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : '');
controls[field.key] = new UntypedFormControl(initialValue, validators);
if (field.type === FormFieldType.PASSWORD) {
this.passwordVisible[field.key] = false;
}
}
ws.formGroup = new UntypedFormGroup(controls);
}
private onStepActivated(): void {
const step = this.currentWizardStep;
if (!step) {
return;
}
if (step.type === 'instruction') {
const raw = this.zipFiles.get(step.rawSteps[0].file) || '';
step.markdown = this.resolveVariables(raw);
} else if (step.type === 'progress' && !step.progressDone) {
this.initAndRunEntitySteps(step);
}
}
private initAndRunEntitySteps(ws: WizardStep): void {
ws.entitySteps = ws.rawSteps.map(s => ({
step: s,
status: 'pending' as const,
resolvedName: this.resolveVariables(s.name)
}));
ws.progressError = null;
ws.progressDone = false;
this.runEntitySteps(ws);
}
private async runEntitySteps(ws: WizardStep): Promise<void> {
for (const ep of ws.entitySteps) {
if (ep.status === 'success') {
continue;
}
ep.status = 'running';
ep.errorMessage = null;
this.cdr.detectChanges();
try {
const startTime = Date.now();
const output = await this.createEntity(ep.step);
// Ensure minimum visible time per step
const elapsed = Date.now() - startTime;
if (elapsed < ENTITY_STEP_MIN_DELAY) {
await this.delay(ENTITY_STEP_MIN_DELAY - elapsed);
}
ep.entityOutput = output;
ep.status = 'success';
const alias = stepTypeAliasMap[ep.step.type];
if (alias) {
this.entityOutputs.set(alias, output);
}
const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || [];
const stepIdx = rawSteps.indexOf(ep.step);
if (stepIdx >= 0) {
this.positionalOutputs.set(stepIdx + 1, output);
}
// Re-resolve names of remaining pending steps
for (const remaining of ws.entitySteps) {
if (remaining.status === 'pending') {
remaining.resolvedName = this.resolveVariables(remaining.step.name);
}
}
this.cdr.detectChanges();
} catch (err: any) {
ep.status = 'error';
ep.errorMessage = err?.error?.message || err?.message || 'Unknown error';
ws.progressError = ep.errorMessage;
this.cdr.detectChanges();
return;
}
}
// All done — register install with created entity IDs
ws.progressDone = true;
ws.completed = true;
try {
const createdEntityIds = this.collectCreatedEntityIds();
const dashboardId = this.findCreatedDashboardId();
await firstValueFrom(
this.data.iotHubApiService.registerDeviceInstall(
this.data.item.id as string,
{ createdEntityIds, dashboardId },
{ ignoreLoading: true }
)
);
} catch (_e) {
// Non-critical — entities are created, tracking registration failed
console.error('Failed to register device install', _e);
}
// Auto-advance to next step after a short pause
if (!this.isLastWizardStep) {
await this.delay(500);
this.stepper.next();
this.cdr.detectChanges();
this.onStepActivated();
}
}
private async createEntity(step: DeviceInstallStep): Promise<EntityStepOutput> {
const raw = this.zipFiles.get(step.template);
if (!raw) {
throw new Error(`Template file not found: ${step.template}`);
}
const resolved = this.resolveVariables(raw);
const template = JSON.parse(resolved);
switch (step.type) {
case InstallStepType.DEVICE_PROFILE: {
const existing = await this.findDeviceProfileByName(template.name);
if (existing) {
return existing;
}
const result = await firstValueFrom(this.deviceProfileService.saveDeviceProfile(template, {ignoreErrors: true}));
return { id: result.id.id, name: result.name };
}
case InstallStepType.DEVICE: {
const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true}));
const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(result.id.id, false, {ignoreErrors: true}));
return { id: result.id.id, name: result.name, token: creds.credentialsId };
}
case InstallStepType.DASHBOARD: {
const result = await firstValueFrom(this.dashboardService.saveDashboard(template, {ignoreErrors: true}));
return { id: result.id.id, name: result.title };
}
case InstallStepType.RULE_CHAIN: {
const ruleChain = template.ruleChain || template;
const metadata = template.metadata;
const existing = await this.findRuleChainByName(ruleChain.name);
if (existing) {
return existing;
}
const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true}));
if (metadata) {
metadata.ruleChainId = saved.id;
await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true}));
}
return { id: saved.id.id, name: saved.name };
}
default:
throw new Error(`Unsupported entity step type: ${step.type}`);
}
}
private async findDeviceProfileByName(name: string): Promise<EntityStepOutput | null> {
const profiles = await firstValueFrom(this.deviceProfileService.getDeviceProfileNames());
const match = profiles.find(p => p.name === name);
return match ? { id: match.id.id, name: match.name } : null;
}
private async findRuleChainByName(name: string): Promise<EntityStepOutput | null> {
const page = await firstValueFrom(this.ruleChainService.getRuleChains(new PageLink(100, 0, name)));
const match = page.data.find(rc => rc.name === name);
return match ? { id: match.id.id, name: match.name } : null;
}
private collectCreatedEntityIds(): { entityType: string; id: string }[] {
const ids: { entityType: string; id: string }[] = [];
for (const ws of this.wizardSteps) {
if (ws.type === 'progress' && ws.entitySteps) {
for (const ep of ws.entitySteps) {
if (ep.status === 'success' && ep.entityOutput) {
const entityType = this.stepTypeToEntityType(ep.step.type);
if (entityType) {
ids.push({ entityType, id: ep.entityOutput.id });
}
}
}
}
}
return ids;
}
private findCreatedDashboardId(): { entityType: string; id: string } | undefined {
for (const ws of this.wizardSteps) {
if (ws.type === 'progress' && ws.entitySteps) {
for (const ep of ws.entitySteps) {
if (ep.step.type === InstallStepType.DASHBOARD && ep.status === 'success' && ep.entityOutput) {
return { entityType: 'DASHBOARD', id: ep.entityOutput.id };
}
}
}
}
return undefined;
}
private stepTypeToEntityType(stepType: InstallStepType): string | null {
switch (stepType) {
case InstallStepType.DEVICE_PROFILE: return 'DEVICE_PROFILE';
case InstallStepType.DEVICE: return 'DEVICE';
case InstallStepType.DASHBOARD: return 'DASHBOARD';
case InstallStepType.RULE_CHAIN: return 'RULE_CHAIN';
default: return null;
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

24
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts

@ -35,6 +35,7 @@ import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from '.
import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component';
import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component';
import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component'; import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component';
import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component';
interface SortOption { interface SortOption {
value: string; value: string;
@ -429,6 +430,10 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
} }
installItem(item: MpItemVersionView): void { installItem(item: MpItemVersionView): void {
if (item.type === ItemType.DEVICE) {
this.installDevice(item);
return;
}
const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, {
panelClass: ['tb-dialog'], panelClass: ['tb-dialog'],
autoFocus: false, autoFocus: false,
@ -444,6 +449,25 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
}); });
} }
private installDevice(item: MpItemVersionView): void {
this.iotHubApiService.getVersionFileData(item.id as string, { ignoreLoading: true }).subscribe({
next: async (blob: Blob) => {
const zipData = await blob.arrayBuffer();
const dialogRef = this.dialog.open(TbDeviceInstallDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
disableClose: true,
autoFocus: false,
data: { item, zipData, iotHubApiService: this.iotHubApiService } as DeviceInstallDialogData
});
dialogRef.afterClosed().subscribe(result => {
if (result === 'installed') {
this.loadItems();
}
});
}
});
}
updateItem(item: MpItemVersionView): void { updateItem(item: MpItemVersionView): void {
const installedItem = this.getInstalledItem(item); const installedItem = this.getInstalledItem(item);
if (!installedItem) { if (!installedItem) {

24
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts

@ -28,6 +28,7 @@ import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from '.
import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component';
import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component';
import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component'; import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component';
import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component';
interface CategoryCard { interface CategoryCard {
type: ItemType; type: ItemType;
@ -232,6 +233,10 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
} }
installItem(item: MpItemVersionView): void { installItem(item: MpItemVersionView): void {
if (item.type === ItemType.DEVICE) {
this.installDevice(item);
return;
}
const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, {
panelClass: ['tb-dialog'], panelClass: ['tb-dialog'],
autoFocus: false, autoFocus: false,
@ -247,6 +252,25 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}); });
} }
private installDevice(item: MpItemVersionView): void {
this.iotHubApiService.getVersionFileData(item.id as string, { ignoreLoading: true }).subscribe({
next: async (blob: Blob) => {
const zipData = await blob.arrayBuffer();
const dialogRef = this.dialog.open(TbDeviceInstallDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
disableClose: true,
autoFocus: false,
data: { item, zipData, iotHubApiService: this.iotHubApiService } as DeviceInstallDialogData
});
dialogRef.afterClosed().subscribe(result => {
if (result === 'installed') {
this.reloadInstalledItems(item.type);
}
});
}
});
}
updateItem(item: MpItemVersionView): void { updateItem(item: MpItemVersionView): void {
const installedItem = this.findInstalledItem(item); const installedItem = this.findInstalledItem(item);
if (!installedItem) { if (!installedItem) {

2
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts

@ -170,6 +170,7 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit {
case 'DASHBOARD': return descriptor.dashboardId?.id; case 'DASHBOARD': return descriptor.dashboardId?.id;
case 'CALCULATED_FIELD': return descriptor.entityId?.id; case 'CALCULATED_FIELD': return descriptor.entityId?.id;
case 'RULE_CHAIN': return descriptor.ruleChainId?.id; case 'RULE_CHAIN': return descriptor.ruleChainId?.id;
case 'DEVICE': return descriptor.dashboardId?.id ?? null;
case 'SOLUTION_TEMPLATE': return descriptor.dashboardId?.id; case 'SOLUTION_TEMPLATE': return descriptor.dashboardId?.id;
default: return null; default: return null;
} }
@ -182,6 +183,7 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit {
case 'DASHBOARD': return EntityType.DASHBOARD; case 'DASHBOARD': return EntityType.DASHBOARD;
case 'CALCULATED_FIELD': return descriptor.entityId?.entityType as EntityType; case 'CALCULATED_FIELD': return descriptor.entityId?.entityType as EntityType;
case 'RULE_CHAIN': return EntityType.RULE_CHAIN; case 'RULE_CHAIN': return EntityType.RULE_CHAIN;
case 'DEVICE': return descriptor.dashboardId ? EntityType.DASHBOARD : null;
case 'SOLUTION_TEMPLATE': return EntityType.DASHBOARD; case 'SOLUTION_TEMPLATE': return EntityType.DASHBOARD;
default: return null; default: return null;
} }

29
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts

@ -27,6 +27,7 @@ import { getEntityDetailsPageURL } from '@core/utils';
import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component';
import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component';
import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component'; import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component';
import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component';
import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component';
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models';
@ -239,6 +240,10 @@ export class TbIotHubItemDetailDialogComponent {
} }
install(): void { install(): void {
if (this.item.type === ItemType.DEVICE) {
this.installDevice();
return;
}
const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, {
panelClass: ['tb-dialog'], panelClass: ['tb-dialog'],
data: { data: {
@ -253,6 +258,30 @@ export class TbIotHubItemDetailDialogComponent {
}); });
} }
private installDevice(): void {
const versionId = this.item.id as string;
this.data.iotHubApiService.getVersionFileData(versionId, { ignoreLoading: true }).subscribe({
next: async (blob: Blob) => {
const zipData = await blob.arrayBuffer();
const dialogRef = this.dialog.open(TbDeviceInstallDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
disableClose: true,
autoFocus: false,
data: {
item: this.item,
zipData,
iotHubApiService: this.data.iotHubApiService
} as DeviceInstallDialogData
});
dialogRef.afterClosed().subscribe(result => {
if (result === 'installed') {
this.dialogRef.close('installed');
}
});
}
});
}
updateItem(): void { updateItem(): void {
const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, {
panelClass: ['tb-dialog'], panelClass: ['tb-dialog'],

4
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts

@ -28,6 +28,7 @@ import { TbIotHubItemCardComponent } from './iot-hub-item-card.component';
import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component';
import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component'; import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component';
import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component'; import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component';
import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -40,7 +41,8 @@ import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component
TbIotHubItemCardComponent, TbIotHubItemCardComponent,
TbIotHubInstalledItemsComponent, TbIotHubInstalledItemsComponent,
TbIotHubUpdateDialogComponent, TbIotHubUpdateDialogComponent,
TbIotHubDeleteDialogComponent TbIotHubDeleteDialogComponent,
TbDeviceInstallDialogComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

134
ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts

@ -0,0 +1,134 @@
///
/// 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.
///
export enum ConnectivityType {
DIRECT_HTTP = 'DIRECT_HTTP',
DIRECT_MQTT = 'DIRECT_MQTT',
DIRECT_COAP = 'DIRECT_COAP',
DIRECT_LWM2M = 'DIRECT_LWM2M',
DIRECT_SNMP = 'DIRECT_SNMP',
GATEWAY_MQTT = 'GATEWAY_MQTT',
GATEWAY_MODBUS = 'GATEWAY_MODBUS',
GATEWAY_OPCUA = 'GATEWAY_OPCUA',
CHIRPSTACK = 'CHIRPSTACK',
INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK',
INTEGRATION_TTN = 'INTEGRATION_TTN',
INTEGRATION_LORIOT = 'INTEGRATION_LORIOT'
}
export const connectivityTypeTranslations = new Map<string, string>(
[
[ConnectivityType.DIRECT_HTTP, 'HTTP'],
[ConnectivityType.DIRECT_MQTT, 'MQTT'],
[ConnectivityType.DIRECT_COAP, 'CoAP'],
[ConnectivityType.DIRECT_LWM2M, 'LwM2M'],
[ConnectivityType.DIRECT_SNMP, 'SNMP'],
[ConnectivityType.GATEWAY_MQTT, 'MQTT Gateway'],
[ConnectivityType.GATEWAY_MODBUS, 'Modbus Gateway'],
[ConnectivityType.GATEWAY_OPCUA, 'OPC-UA Gateway'],
[ConnectivityType.CHIRPSTACK, 'ChirpStack'],
[ConnectivityType.INTEGRATION_CHIRPSTACK, 'ChirpStack (PE)'],
[ConnectivityType.INTEGRATION_TTN, 'The Things Stack'],
[ConnectivityType.INTEGRATION_LORIOT, 'LORIOT']
]
);
export enum InstallStepType {
SHOW_INSTRUCTION = 'SHOW_INSTRUCTION',
SHOW_FORM = 'SHOW_FORM',
DEVICE_PROFILE = 'DEVICE_PROFILE',
CONVERTER = 'CONVERTER',
INTEGRATION = 'INTEGRATION',
DEVICE = 'DEVICE',
DASHBOARD = 'DASHBOARD',
RULE_CHAIN = 'RULE_CHAIN'
}
export const ENTITY_STEP_TYPES: Set<string> = new Set([
InstallStepType.DEVICE_PROFILE,
InstallStepType.DEVICE,
InstallStepType.DASHBOARD,
InstallStepType.RULE_CHAIN
]);
export const stepTypeAliasMap: Record<string, string> = {
[InstallStepType.DEVICE_PROFILE]: 'deviceProfile',
[InstallStepType.DEVICE]: 'device',
[InstallStepType.DASHBOARD]: 'dashboard',
[InstallStepType.RULE_CHAIN]: 'ruleChain'
};
export interface DeviceInstallStep {
type: InstallStepType;
name: string;
file?: string;
template?: string;
}
export interface DevicePackageInfo {
name: string;
description: string;
vendor: string;
hardwareType: string;
connectivityTypes: string[];
installSteps: Record<string, DeviceInstallStep[]>;
}
export enum FormFieldType {
STRING = 'STRING',
INTEGER = 'INTEGER',
BOOLEAN = 'BOOLEAN',
SELECT = 'SELECT',
PASSWORD = 'PASSWORD'
}
export interface FormFieldValidator {
pattern: string;
message: string;
}
export interface FormFieldOption {
value: string;
label: string;
}
export interface FormFieldDefinition {
key: string;
label: string;
type: FormFieldType;
defaultValue?: any;
required?: boolean;
helpText?: string;
helpImage?: string;
validators?: FormFieldValidator[];
options?: FormFieldOption[];
}
export interface EntityStepOutput {
id: string;
name: string;
token?: string;
}
export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error';
export interface EntityStepProgress {
step: DeviceInstallStep;
status: EntityStepStatus;
resolvedName?: string;
entityOutput?: EntityStepOutput;
errorMessage?: string;
}

2
ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts

@ -39,6 +39,8 @@ export interface RuleChainInstalledItemDescriptor {
export interface DeviceInstalledItemDescriptor { export interface DeviceInstalledItemDescriptor {
type: 'DEVICE'; type: 'DEVICE';
createdEntityIds?: { entityType: string; id: string }[];
dashboardId?: { id: string };
} }
export interface SolutionTemplateInstalledItemDescriptor { export interface SolutionTemplateInstalledItemDescriptor {

20
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -82,7 +82,8 @@
"upload": "Upload", "upload": "Upload",
"delete-anyway": "Delete anyway", "delete-anyway": "Delete anyway",
"delete-selected": "Delete selected", "delete-selected": "Delete selected",
"set": "Set" "set": "Set",
"retry": "Retry"
}, },
"aggregation": { "aggregation": {
"aggregation": "Aggregation", "aggregation": "Aggregation",
@ -3864,7 +3865,22 @@
"open-installed-entity": "Open installed entity", "open-installed-entity": "Open installed entity",
"view-item-details": "View item details", "view-item-details": "View item details",
"open-item-type-details": "Open {{type}} details", "open-item-type-details": "Open {{type}} details",
"back-to-marketplace": "Back to Marketplace" "back-to-marketplace": "Back to Marketplace",
"device-install-connection": "Connection: {{type}}",
"device-install-entity-error": "Failed to create {{name}}: {{error}}",
"device-install-expand-image": "Click to expand",
"device-install-select-connectivity": "Select your connection type:",
"device-install-step-error": "Failed",
"device-install-step-pending": "Pending",
"device-install-step-running": "Creating...",
"device-install-step-success": "Created",
"device-install-step-type-DASHBOARD": "Dashboard",
"device-install-step-type-DEVICE": "Device",
"device-install-step-type-DEVICE_PROFILE": "Device Profile",
"device-install-step-type-RULE_CHAIN": "Rule Chain",
"device-install-title": "Install \"{{name}}\"",
"device-install-complete-title": "Install \"{{name}}\" — Complete",
"device-installing-title": "Installing \"{{name}}\""
}, },
"item-data": { "item-data": {
"widget-type-timeseries": "Timeseries", "widget-type-timeseries": "Timeseries",

Loading…
Cancel
Save