Browse Source

Fix device-install wizard treating BOOLEAN/INTEGER form fields as strings

The device-install wizard substituted ${...} placeholders via a plain
string replace, so a quoted boolean macro like "cleanSession":
"${mqttCleanSession}" parsed as the string "false" (truthy) instead of
the JSON boolean false. Unchecking a boolean field (e.g. clean session)
therefore had no effect and read as true in the resulting integration.

Add a type-aware resolveTemplateJson() that, using the form-field type
recorded in form.json, emits BOOLEAN/INTEGER placeholders occupying a
whole quoted token as raw JSON values (unquoted) while leaving string
fields and embedded placeholders quoted. Route all template-parse call
sites through it. Fixes cleanSession, ssl, port and connectTimeoutSec
across all existing device packages without any template changes.
pull/15678/merge
Andrii Shvaika 3 weeks ago
committed by Vladyslav Prykhodko
parent
commit
684f2b7e96
  1. 51
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts

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

@ -114,6 +114,9 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
formValues: Record<string, any> = {};
entityOutputs = new Map<string, EntityStepOutput>();
transportVars: Record<string, string> = {};
// form-field key → declared type. Used by resolveTemplateJson() to emit BOOLEAN/INTEGER
// placeholders as raw JSON values instead of quoted strings.
private fieldTypes = new Map<string, FormFieldType>();
gatewayDockerComposeContent: string | null = null;
resolveMarkdownVariable: (key: string) => string | undefined = this._resolveMarkdownVariable.bind(this);
@ -499,6 +502,38 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
return undefined;
}
/**
* Resolve a JSON template, type-aware. A placeholder that fills an entire JSON string
* token (e.g. "${mqttCleanSession}") whose form field is BOOLEAN or INTEGER is emitted as
* a raw JSON boolean/number rather than a quoted string. Authored templates always quote
* macros so the template file is valid JSON; the recorded form-field type decides the real
* type at install time. Without this, "cleanSession": "${mqttCleanSession}" parses as the
* string "false" (truthy) so an unchecked boolean would be read as true. Placeholders
* embedded in a larger string (e.g. "v3/${mqttUsername}/...") and STRING/PASSWORD/SELECT
* fields are left quoted and handled by the regular resolveVariables() pass.
*/
resolveTemplateJson(raw: string): any {
const typed = raw.replace(/"\$\{([^"{}]+)}"/g, (whole, key) => {
const type = this.fieldTypes.get(key);
if (type !== FormFieldType.BOOLEAN && type !== FormFieldType.INTEGER) {
return whole;
}
const res = this.resolveVariable(key);
if (res === undefined) {
return whole;
}
if (type === FormFieldType.BOOLEAN) {
return String(res).trim().toLowerCase() === 'true' ? 'true' : 'false';
}
// INTEGER: emit a raw JSON number only when the value really is an integer;
// otherwise leave it quoted for the regular pass (preserves prior behaviour).
const trimmed = String(res).trim();
const num = Number(trimmed);
return trimmed !== '' && Number.isInteger(num) ? String(num) : whole;
});
return JSON.parse(this.resolveVariables(typed));
}
private _resolveMarkdownVariable(key: string): string | undefined {
const res = this.resolveVariable(key);
if (res) {
@ -648,6 +683,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
// In review mode, use stored values; otherwise use defaults
const storedValues = this.reviewMode && this.data.installState?.[ws.label]?.formValues;
for (const field of ws.formFields) {
this.fieldTypes.set(field.key, field.type);
const validators = [];
if (field.required) {
validators.push(Validators.required);
@ -806,8 +842,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
if (!raw) {
throw new Error(`Template file not found: ${step.template}`);
}
const resolved = this.resolveVariables(raw);
const template = JSON.parse(resolved);
const template = this.resolveTemplateJson(raw);
switch (step.type) {
case InstallStepType.DEVICE_PROFILE: {
@ -915,7 +950,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
if (step.credentials) {
const raw = this.zipFiles.get(step.credentials);
if (raw) {
const credTemplate = JSON.parse(this.resolveVariables(raw));
const credTemplate = this.resolveTemplateJson(raw);
creds.credentialsType = credTemplate.credentialsType || creds.credentialsType;
if (credTemplate.credentialsValue) {
creds.credentialsValue = typeof credTemplate.credentialsValue === 'string'
@ -964,8 +999,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
private async overwriteEntity(step: DeviceInstallStep, existing: EntityStepOutput): 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);
const template = this.resolveTemplateJson(raw);
switch (step.type) {
case InstallStepType.DEVICE_PROFILE: {
@ -1016,8 +1050,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
private async preCheckEntity(step: DeviceInstallStep): Promise<EntityStepOutput | null> {
const raw = this.zipFiles.get(step.template);
if (!raw) return null;
const resolved = this.resolveVariables(raw);
const template = JSON.parse(resolved);
const template = this.resolveTemplateJson(raw);
switch (step.type) {
case InstallStepType.DEVICE_PROFILE:
@ -1095,7 +1128,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
if (step.serverAttributes) {
const raw = this.zipFiles.get(step.serverAttributes);
if (raw) {
const resolved = JSON.parse(this.resolveVariables(raw));
const resolved = this.resolveTemplateJson(raw);
const attrs = Object.entries(resolved).map(([key, value]) => ({ key, value }));
await firstValueFrom(this.attributeService.saveEntityAttributes(
entityId, AttributeScope.SERVER_SCOPE, attrs, {ignoreErrors: true}
@ -1105,7 +1138,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
if (step.sharedAttributes) {
const raw = this.zipFiles.get(step.sharedAttributes);
if (raw) {
const resolved = JSON.parse(this.resolveVariables(raw));
const resolved = this.resolveTemplateJson(raw);
const attrs = Object.entries(resolved).map(([key, value]) => ({ key, value }));
await firstValueFrom(this.attributeService.saveEntityAttributes(
entityId, AttributeScope.SHARED_SCOPE, attrs, {ignoreErrors: true}

Loading…
Cancel
Save