diff --git a/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java b/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java index 316f9baf1b..8a6b2c0971 100644 --- a/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java +++ b/manager/src/main/java/org/openremote/manager/asset/AssetResourceImpl.java @@ -47,12 +47,17 @@ import org.openremote.model.asset.Asset; import org.openremote.model.asset.AssetResource; import org.openremote.model.asset.AssetTree; +import org.openremote.model.asset.AttributeConfigurationDocument; +import org.openremote.model.asset.AttributeConfigurationImportFailure; +import org.openremote.model.asset.AttributeConfigurationImportPreview; +import org.openremote.model.asset.AttributeConfigurationImportRequest; import org.openremote.model.asset.UserAssetLink; import org.openremote.model.attribute.*; import org.openremote.model.http.RequestParams; import org.openremote.model.query.AssetQuery; import org.openremote.model.query.filter.RealmPredicate; import org.openremote.model.security.ClientRole; +import org.openremote.model.util.AttributeConfigurationUtil; import org.openremote.model.util.TextUtil; import org.openremote.model.util.ValueUtil; @@ -466,6 +471,79 @@ public Asset update(RequestParams requestParams, String assetId, Asset ass } } + @Override + public AttributeConfigurationDocument exportAttributeConfiguration( + RequestParams requestParams, + String assetId, + List attributeNames, + List genericPaths) { + // Reuses the read access rules of #get, including the protected view for a restricted user + Asset asset = get(requestParams, assetId); + + try { + return AttributeConfigurationUtil.export(asset, attributeNames, genericPaths); + } catch (IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } + } + + @Override + public AttributeConfigurationImportPreview previewAttributeConfigurationImport( + RequestParams requestParams, String assetId, AttributeConfigurationImportRequest request) { + + Asset storageAsset = assetStorageService.find(assetId, true); + + if (storageAsset == null) { + LOG.fine("Asset not found: assetID=" + assetId); + throw new WebApplicationException(NOT_FOUND); + } + + throwIfNotRealmActiveAndAccessible(storageAsset.getRealm()); + + if (isRestrictedUser() && !assetStorageService.isUserAsset(getUserId(), assetId)) { + LOG.fine( + "Forbidden access for restricted user: username=" + + getUsername() + + ", assetID=" + + assetId); + throw new WebApplicationException(FORBIDDEN); + } + + if (request == null || request.getDraft() == null) { + throw new WebApplicationException("An asset draft is required", BAD_REQUEST); + } + + Asset draft = request.getDraft(); + + if (!Objects.equals(assetId, draft.getId()) + || !Objects.equals(storageAsset.getRealm(), draft.getRealm()) + || !Objects.equals(storageAsset.getType(), draft.getType())) { + throw importFailure( + AttributeConfigurationImportPreview.failed( + AttributeConfigurationImportFailure.DRAFT_MISMATCH)); + } + + AttributeConfigurationImportPreview preview = + AttributeConfigurationUtil.preview( + draft, request.getDocument(), request.getGenericParameterValues()); + + if (preview.getFailure() != null) { + throw importFailure(preview); + } + + return preview; + } + + protected WebApplicationException importFailure(AttributeConfigurationImportPreview preview) { + Response.Status status = + preview.getFailure() == AttributeConfigurationImportFailure.DRAFT_MISMATCH + ? CONFLICT + : BAD_REQUEST; + + return new WebApplicationException( + Response.status(status).entity(preview).type(MediaType.APPLICATION_JSON_TYPE).build()); + } + @Override public AttributeWriteResult writeAttributeValue( RequestParams requestParams, String assetId, String attributeName, Object value) { diff --git a/model/src/main/java/org/openremote/model/asset/AssetResource.java b/model/src/main/java/org/openremote/model/asset/AssetResource.java index db616548b8..680d5aa241 100644 --- a/model/src/main/java/org/openremote/model/asset/AssetResource.java +++ b/model/src/main/java/org/openremote/model/asset/AssetResource.java @@ -344,6 +344,97 @@ Asset update( value = ASSET_UPDATE))) Asset asset); + /** + * Retrieve the attribute configuration of the asset as a user editable document, holding the type + * and metadata of every attribute that has metadata. Repeat the attributeName query parameter to + * export a subset of them, and the genericPath query parameter to leave values out for the user + * to supply on import. A 400 status is returned if a generic path does not hold the same text, + * boolean or number in every exported attribute holding it. Access follows the same rules as + * {@link #get}. + */ + @GET + @Path("{assetId}/attributeConfiguration") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ASSETS_ROLE}) + @Operation( + operationId = "exportAttributeConfiguration", + summary = "Export the attribute configuration of an asset", + description = + "Returns a versioned document holding the type and metadata of each attribute that has metadata, limited to the requested attribute names when any are given. Attributes without metadata are never included. Values at the requested generic paths are left out and recorded as generic parameters, named after their path, for the user to supply on import.") + @OpenApiResponses.Ok + @OpenApiResponses.Authenticated + @OpenApiResponses.BadRequest + @OpenApiResponses.NotFound + AttributeConfigurationDocument exportAttributeConfiguration( + @BeanParam RequestParams requestParams, + @Parameter(description = ASSET_ID, example = EXAMPLE_ASSET_ID) @PathParam("assetId") + String assetId, + @Parameter( + description = + "Attribute names to export. Repeat the query parameter for multiple attributes, omit it to export all attributes that have metadata.", + style = ParameterStyle.FORM, + explode = Explode.TRUE) + @QueryParam("attributeName") + List attributeNames, + @Parameter( + description = + "Paths relative to an attribute, such as meta.agentLink.id, whose value becomes a generic parameter. Each must hold the same text, boolean or number in every exported attribute holding it. Repeat the query parameter for multiple paths.", + example = "meta.agentLink.id", + style = ParameterStyle.FORM, + explode = Explode.TRUE) + @QueryParam("genericPath") + List genericPaths); + + /** + * Validate an attribute configuration document against an asset draft and report what importing + * it would do, without storing anything. The returned patched attributes are for the client to + * apply to the draft once the user confirms, so the same access as {@link #update} is required. A + * 400 status is returned if no draft is given or the document cannot be imported, including while + * its generic parameters lack a valid value, and a 409 status if the draft no longer identifies + * the persisted asset. A rejected document is answered with the preview, whose failure names the + * reason. + */ + @POST + @Path("{assetId}/attributeConfiguration/import") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ASSETS_ROLE}) + @Operation( + operationId = "previewAttributeConfigurationImport", + summary = "Preview an attribute configuration import", + description = + "Validates the document, matches its attributes against the draft by name and type, and returns the compatibility report together with the draft attributes holding the imported metadata. When the document has generic parameters, the report lists those needing a value and is rejected until valid values are submitted. Nothing is stored; the client applies the patch after the user confirms.") + @OpenApiResponses.Ok + @OpenApiResponses.Authenticated + @ApiResponse( + responseCode = "400", + description = + "The draft is missing, or the document cannot be imported; a rejected document is answered with the preview, whose failure names the reason", + content = { + @Content( + mediaType = APPLICATION_JSON, + schema = @Schema(implementation = AttributeConfigurationImportPreview.class)), + @Content(mediaType = "text/plain", schema = @Schema(implementation = String.class)) + }) + @OpenApiResponses.NotFound + @ApiResponse( + responseCode = "409", + description = + "The draft no longer identifies the persisted asset; the preview failure is DRAFT_MISMATCH", + content = + @Content( + mediaType = APPLICATION_JSON, + schema = @Schema(implementation = AttributeConfigurationImportPreview.class))) + AttributeConfigurationImportPreview previewAttributeConfigurationImport( + @BeanParam RequestParams requestParams, + @Parameter(description = ASSET_ID, example = EXAMPLE_ASSET_ID) @PathParam("assetId") + String assetId, + @RequestBody( + required = true, + description = + "The asset draft being edited, the contents of the selected configuration file and the values of its generic parameters.") + @NotNull AttributeConfigurationImportRequest request); + /** * Updates an attribute of an asset. Regular users can only update assets in their authenticated * realm, the superuser can update assets in other (all) realms. A 403 status is returned if a diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfiguration.java b/model/src/main/java/org/openremote/model/asset/AttributeConfiguration.java new file mode 100644 index 0000000000..74ab14331e --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfiguration.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * The configuration of a single {@link org.openremote.model.attribute.Attribute} within an {@link + * AttributeConfigurationDocument}. The attribute name is the document map key, so it is not + * repeated here. + */ +@Schema( + description = + "Configuration of a single attribute; the attribute name is the key within the document.") +public class AttributeConfiguration { + + @Schema(description = "Value descriptor name of the attribute, e.g. number.") + protected String type; + + @Schema(description = "Metadata keyed by meta-item name, exactly as stored on the attribute.") + protected ObjectNode meta; + + @JsonCreator + public AttributeConfiguration( + @JsonProperty("type") String type, @JsonProperty("meta") ObjectNode meta) { + this.type = type; + this.meta = meta; + } + + public String getType() { + return type; + } + + public ObjectNode getMeta() { + return meta; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java new file mode 100644 index 0000000000..19c8771529 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationDocument.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; + +/** + * A user editable document holding the configuration of the {@link + * org.openremote.model.attribute.Attribute}s of an {@link Asset}, exchanged as a file by the + * Manager UI. Attributes are keyed by attribute name. + */ +@Schema( + description = + "Versioned document holding attribute configuration of an asset, keyed by attribute name.") +public class AttributeConfigurationDocument { + + /** The only document version this release can read and write. */ + public static final int VERSION = 1; + + @Schema(description = "Document format version.", example = "1") + protected int version; + + @Schema(description = "Asset type the configuration was exported from.") + protected String assetType; + + @Schema( + description = + "Values left out of the attribute configuration, keyed by parameter name, for the user to supply on import.") + protected Map genericParameters; + + @Schema(description = "Attribute configuration keyed by attribute name.") + protected Map attributes; + + @JsonCreator + public AttributeConfigurationDocument( + @JsonProperty("version") int version, + @JsonProperty("assetType") String assetType, + @JsonProperty("genericParameters") + Map genericParameters, + @JsonProperty("attributes") Map attributes) { + this.version = version; + this.assetType = assetType; + this.genericParameters = genericParameters; + this.attributes = attributes; + } + + public int getVersion() { + return version; + } + + public String getAssetType() { + return assetType; + } + + public Map getGenericParameters() { + return genericParameters; + } + + public Map getAttributes() { + return attributes; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationGenericParameter.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationGenericParameter.java new file mode 100644 index 0000000000..7d6c7a1ebd --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationGenericParameter.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** + * A value that an {@link AttributeConfigurationDocument} leaves out and asks for on import, applied + * to every path it lists. The parameter name is the document map key, so it is not repeated here. + */ +@Schema( + description = + "A value supplied on import and applied to every listed path; the parameter name is the key within the document.") +public class AttributeConfigurationGenericParameter { + + @Schema( + description = + "Value descriptor name of the value, one of text, boolean, integer, long or number.", + example = "text") + protected String type; + + @Schema( + description = + "Dot separated paths within the document that receive the value, each of the form attributes..meta.[....].", + example = "[\"attributes.error.meta.agentLink.id\"]") + protected List paths; + + @JsonCreator + public AttributeConfigurationGenericParameter( + @JsonProperty("type") String type, @JsonProperty("paths") List paths) { + this.type = type; + this.paths = paths; + } + + public String getType() { + return type; + } + + public List getPaths() { + return paths; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java new file mode 100644 index 0000000000..43ada6a6b6 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportFailure.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +/** Reason why an {@link AttributeConfigurationDocument} cannot be imported. */ +public enum AttributeConfigurationImportFailure { + + /** The document is not parsable JSON or is not a JSON object. */ + INVALID_JSON, + + /** The document version is not {@link AttributeConfigurationDocument#VERSION}. */ + UNSUPPORTED_VERSION, + + /** The version, asset type or attributes field is absent. */ + MISSING_REQUIRED_FIELD, + + /** The attributes field or one of its entries does not hold a type and a metadata object. */ + MALFORMED_ATTRIBUTE_ENTRY, + + /** + * The generic parameters field or one of its entries does not hold a supported type and paths to + * existing metadata of the document. + */ + MALFORMED_GENERIC_PARAMETER, + + /** No attribute of the document exists on the target asset with the same type. */ + NO_IMPORTABLE_ATTRIBUTES, + + /** A generic parameter of an importable attribute has no value. */ + GENERIC_PARAMETER_VALUES_REQUIRED, + + /** + * A generic parameter value does not match its type or the schema of the metadata it is applied + * to. + */ + INVALID_GENERIC_PARAMETER_VALUE, + + /** The submitted draft no longer identifies the persisted target asset. */ + DRAFT_MISMATCH +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java new file mode 100644 index 0000000000..5559a30fe3 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportPreview.java @@ -0,0 +1,146 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; + +/** + * What importing an {@link AttributeConfigurationDocument} into an asset draft would do, for the + * user to confirm or cancel. With a {@link #failure} nothing can be imported and there are no + * patched attributes, but the other fields still hold what could be worked out, such as the skipped + * attributes or the generic parameters that need a value. + */ +@Schema( + description = + "Outcome of validating an attribute configuration document against an asset draft, with the patched attributes to apply on confirmation.") +public class AttributeConfigurationImportPreview { + + @Schema(description = "Why the document cannot be imported; absent when it can.") + protected AttributeConfigurationImportFailure failure; + + @Schema(description = "Asset type the document was exported from.") + protected String importedAssetType; + + @Schema(description = "Asset type of the target draft.") + protected String targetAssetType; + + @Schema(description = "Whether the document was exported from a different asset type.") + protected boolean assetTypeMismatch; + + @Schema(description = "Attributes whose metadata will be replaced.") + protected List importableAttributes; + + @Schema(description = "Attributes skipped because the target asset does not have them.") + protected List missingAttributes; + + @Schema(description = "Attributes skipped because the target attribute has a different type.") + protected List typeMismatches; + + @Schema( + description = + "Generic parameters that need a value, keyed by parameter name, listing only the paths of importable attributes.") + protected Map genericParameters; + + @Schema( + description = + "Names of the generic parameters whose value does not match their type or the schema of the metadata they are applied to.") + protected List invalidGenericParameters; + + @Schema( + description = + "The submitted draft attributes with the metadata of every importable attribute replaced, keyed by attribute name.") + protected ObjectNode patchedAttributes; + + @JsonCreator + public AttributeConfigurationImportPreview( + @JsonProperty("failure") AttributeConfigurationImportFailure failure, + @JsonProperty("importedAssetType") String importedAssetType, + @JsonProperty("targetAssetType") String targetAssetType, + @JsonProperty("assetTypeMismatch") boolean assetTypeMismatch, + @JsonProperty("importableAttributes") + List importableAttributes, + @JsonProperty("missingAttributes") List missingAttributes, + @JsonProperty("typeMismatches") List typeMismatches, + @JsonProperty("genericParameters") + Map genericParameters, + @JsonProperty("invalidGenericParameters") List invalidGenericParameters, + @JsonProperty("patchedAttributes") ObjectNode patchedAttributes) { + this.failure = failure; + this.importedAssetType = importedAssetType; + this.targetAssetType = targetAssetType; + this.assetTypeMismatch = assetTypeMismatch; + this.importableAttributes = importableAttributes; + this.missingAttributes = missingAttributes; + this.typeMismatches = typeMismatches; + this.genericParameters = genericParameters; + this.invalidGenericParameters = invalidGenericParameters; + this.patchedAttributes = patchedAttributes; + } + + public static AttributeConfigurationImportPreview failed( + AttributeConfigurationImportFailure failure) { + return new AttributeConfigurationImportPreview( + failure, null, null, false, List.of(), List.of(), List.of(), Map.of(), List.of(), null); + } + + public AttributeConfigurationImportFailure getFailure() { + return failure; + } + + public String getImportedAssetType() { + return importedAssetType; + } + + public String getTargetAssetType() { + return targetAssetType; + } + + public boolean isAssetTypeMismatch() { + return assetTypeMismatch; + } + + public List getImportableAttributes() { + return importableAttributes; + } + + public List getMissingAttributes() { + return missingAttributes; + } + + public List getTypeMismatches() { + return typeMismatches; + } + + public Map getGenericParameters() { + return genericParameters; + } + + public List getInvalidGenericParameters() { + return invalidGenericParameters; + } + + public ObjectNode getPatchedAttributes() { + return patchedAttributes; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java new file mode 100644 index 0000000000..28d3340278 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationImportRequest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.Map; + +/** + * An {@link AttributeConfigurationDocument} to import, together with the asset draft it is imported + * into. The document is submitted as text so that unparsable files are reported like any other + * invalid document. + */ +@Schema( + description = + "An attribute configuration document to validate against an asset draft, as file text.") +public class AttributeConfigurationImportRequest { + + @Schema(description = "The asset draft currently being edited, including unsaved changes.") + @NotNull protected Asset draft; + + @Schema(description = "Contents of the selected attribute configuration file.") + @NotNull protected String document; + + @Schema( + description = + "Values of the generic parameters of the document, keyed by parameter name; absent until the user has supplied them.") + protected Map genericParameterValues; + + public AttributeConfigurationImportRequest(Asset draft, String document) { + this(draft, document, null); + } + + @JsonCreator + public AttributeConfigurationImportRequest( + @JsonProperty("draft") Asset draft, + @JsonProperty("document") String document, + @JsonProperty("genericParameterValues") Map genericParameterValues) { + this.draft = draft; + this.document = document; + this.genericParameterValues = genericParameterValues; + } + + public Asset getDraft() { + return draft; + } + + public String getDocument() { + return document; + } + + public Map getGenericParameterValues() { + return genericParameterValues; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationSummary.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationSummary.java new file mode 100644 index 0000000000..50ae0f0249 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationSummary.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** An attribute of an {@link AttributeConfigurationDocument} as reported to the user. */ +@Schema(description = "Name and type of an attribute within an imported document.") +public class AttributeConfigurationSummary { + + @Schema(description = "Attribute name.") + protected String name; + + @Schema(description = "Value descriptor name of the attribute, e.g. number.") + protected String type; + + @JsonCreator + public AttributeConfigurationSummary( + @JsonProperty("name") String name, @JsonProperty("type") String type) { + this.name = name; + this.type = type; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } +} diff --git a/model/src/main/java/org/openremote/model/asset/AttributeConfigurationTypeMismatch.java b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationTypeMismatch.java new file mode 100644 index 0000000000..7271391d72 --- /dev/null +++ b/model/src/main/java/org/openremote/model/asset/AttributeConfigurationTypeMismatch.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.asset; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * An attribute that cannot be imported because the target attribute holds a different value type. + */ +@Schema(description = "Attribute skipped on import because the target type differs.") +public class AttributeConfigurationTypeMismatch { + + @Schema(description = "Attribute name.") + protected String name; + + @Schema(description = "Value descriptor name within the imported document.") + protected String importedType; + + @Schema(description = "Value descriptor name of the target attribute.") + protected String targetType; + + @JsonCreator + public AttributeConfigurationTypeMismatch( + @JsonProperty("name") String name, + @JsonProperty("importedType") String importedType, + @JsonProperty("targetType") String targetType) { + this.name = name; + this.importedType = importedType; + this.targetType = targetType; + } + + public String getName() { + return name; + } + + public String getImportedType() { + return importedType; + } + + public String getTargetType() { + return targetType; + } +} diff --git a/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java b/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java new file mode 100644 index 0000000000..0743ed8bca --- /dev/null +++ b/model/src/main/java/org/openremote/model/util/AttributeConfigurationUtil.java @@ -0,0 +1,608 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.util; + +import static org.openremote.model.asset.AttributeConfigurationImportFailure.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.BiConsumer; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.openremote.model.asset.Asset; +import org.openremote.model.asset.AttributeConfiguration; +import org.openremote.model.asset.AttributeConfigurationDocument; +import org.openremote.model.asset.AttributeConfigurationGenericParameter; +import org.openremote.model.asset.AttributeConfigurationImportPreview; +import org.openremote.model.asset.AttributeConfigurationSummary; +import org.openremote.model.asset.AttributeConfigurationTypeMismatch; +import org.openremote.model.attribute.Attribute; +import org.openremote.model.attribute.AttributeMap; +import org.openremote.model.value.ValueType; + +/** + * Reads and writes {@link AttributeConfigurationDocument}s. Callers are responsible for enforcing + * realm authorization and for persisting, or discarding, the patched attributes. + */ +public final class AttributeConfigurationUtil { + + /** Separates the segments of a generic parameter path. */ + public static final String PATH_SEPARATOR = "."; + + /** Value descriptor names a generic parameter can have, which are those of JSON scalars. */ + public static final Set GENERIC_PARAMETER_TYPES = + Set.of( + ValueType.TEXT.getName(), + ValueType.BOOLEAN.getName(), + ValueType.INTEGER.getName(), + ValueType.LONG.getName(), + ValueType.NUMBER.getName()); + + protected static final String ATTRIBUTES_SEGMENT = "attributes"; + protected static final String META_SEGMENT = "meta"; + + private AttributeConfigurationUtil() {} + + /** + * Builds the export document of an asset, holding the type and metadata of every attribute that + * has metadata. A null or empty selection exports all of them. + */ + public static AttributeConfigurationDocument export( + Asset asset, Collection attributeNames) { + return export(asset, attributeNames, null); + } + + /** + * Builds the export document of an asset like {@link #export(Asset, Collection)}, leaving out the + * values at the given generic paths. Each generic path is relative to an attribute, e.g. {@code + * meta.agentLink.id}, and must be one of the {@link #findGenericPathCandidates} of the exported + * attributes. Its value is recorded as a generic parameter instead, named by {@link + * #deriveGenericParameterName}. + * + * @throws IllegalArgumentException if a generic path is not a candidate + */ + public static AttributeConfigurationDocument export( + Asset asset, Collection attributeNames, Collection genericPaths) { + boolean selectAll = attributeNames == null || attributeNames.isEmpty(); + Map attributes = new TreeMap<>(); + + if (asset.getAttributes() != null) { + asset.getAttributes().values().stream() + .filter(attribute -> attribute.getMeta() != null && !attribute.getMeta().isEmpty()) + .filter(attribute -> selectAll || attributeNames.contains(attribute.getName())) + .forEach( + attribute -> + attributes.put( + attribute.getName(), + new AttributeConfiguration( + typeName(attribute), toObjectNode(attribute.getMeta())))); + } + + Map genericParameters = + extractGenericParameters(attributes, genericPaths); + + return new AttributeConfigurationDocument( + AttributeConfigurationDocument.VERSION, + asset.getType(), + genericParameters.isEmpty() ? null : genericParameters, + attributes); + } + + /** + * Finds the metadata values that can become a generic parameter: text, boolean and number values + * reached through object properties only, which are equal in every attribute holding them. + * Returns their paths relative to an attribute, e.g. {@code meta.agentLink.id}, mapped to the + * names of the attributes holding them. + */ + public static Map> findGenericPathCandidates( + Map attributes) { + Map> holders = new TreeMap<>(); + Map values = new HashMap<>(); + Set differing = new HashSet<>(); + + new TreeMap<>(attributes) + .forEach( + (name, configuration) -> + collectScalars( + configuration.getMeta(), + META_SEGMENT, + (path, value) -> { + holders.computeIfAbsent(path, key -> new ArrayList<>()).add(name); + JsonNode first = values.putIfAbsent(path, value); + if (first != null && !first.equals(value)) { + differing.add(path); + } + })); + + differing.forEach(holders::remove); + return holders; + } + + /** + * Derives a generic parameter name from a path relative to an attribute by joining the segments + * after {@code meta} in camel case, so {@code meta.agentLink.id} becomes {@code agentLinkId}. + */ + public static String deriveGenericParameterName(String path) { + String[] segments = splitPath(path); + StringBuilder name = new StringBuilder(segments[1]); + + for (int i = 2; i < segments.length; i++) { + name.append(Character.toUpperCase(segments[i].charAt(0))).append(segments[i].substring(1)); + } + + return name.toString(); + } + + /** + * Validates a document against an asset draft and works out what importing it would do, without + * changing the draft. The returned {@link + * AttributeConfigurationImportPreview#getPatchedAttributes()} holds the draft attributes with the + * metadata of every importable attribute replaced. + */ + public static AttributeConfigurationImportPreview preview(Asset draft, String document) { + return preview(draft, document, null); + } + + /** + * Validates a document against an asset draft like {@link #preview(Asset, String)}, applying the + * given generic parameter values to the imported metadata. Every generic parameter with a path in + * an importable attribute needs a value of its type, which must also read as the value type of + * the meta item it is applied to. + */ + public static AttributeConfigurationImportPreview preview( + Asset draft, String document, Map genericParameterValues) { + JsonNode root; + + try { + root = ValueUtil.JSON.readTree(document); + } catch (JsonProcessingException | IllegalArgumentException e) { + return AttributeConfigurationImportPreview.failed(INVALID_JSON); + } + + if (root == null || !root.isObject()) { + return AttributeConfigurationImportPreview.failed(INVALID_JSON); + } + + JsonNode version = root.get("version"); + JsonNode assetType = root.get("assetType"); + JsonNode attributes = root.get("attributes"); + JsonNode genericParameters = root.get("genericParameters"); + + if (isAbsent(version) || isAbsent(assetType) || isAbsent(attributes)) { + return AttributeConfigurationImportPreview.failed(MISSING_REQUIRED_FIELD); + } + if (!version.isIntegralNumber() || version.asInt() != AttributeConfigurationDocument.VERSION) { + return AttributeConfigurationImportPreview.failed(UNSUPPORTED_VERSION); + } + if (!assetType.isTextual() || assetType.asText().isBlank()) { + return AttributeConfigurationImportPreview.failed(MISSING_REQUIRED_FIELD); + } + if (!attributes.isObject()) { + return AttributeConfigurationImportPreview.failed(MALFORMED_ATTRIBUTE_ENTRY); + } + + // The map key is the attribute name, so an entry's own name field, if any, is ignored + Map imported = new LinkedHashMap<>(); + for (Map.Entry entry : attributes.properties()) { + JsonNode type = entry.getValue().isObject() ? entry.getValue().get("type") : null; + JsonNode meta = entry.getValue().isObject() ? entry.getValue().get("meta") : null; + + if (type == null || !type.isTextual() || type.asText().isBlank()) { + return AttributeConfigurationImportPreview.failed(MALFORMED_ATTRIBUTE_ENTRY); + } + if (meta == null || !meta.isObject()) { + return AttributeConfigurationImportPreview.failed(MALFORMED_ATTRIBUTE_ENTRY); + } + + imported.put( + entry.getKey(), new AttributeConfiguration(type.asText(), (ObjectNode) meta.deepCopy())); + } + + Map declared = new LinkedHashMap<>(); + if (!isAbsent(genericParameters)) { + if (!genericParameters.isObject()) { + return AttributeConfigurationImportPreview.failed(MALFORMED_GENERIC_PARAMETER); + } + + Set declaredPaths = new HashSet<>(); + for (Map.Entry entry : genericParameters.properties()) { + AttributeConfigurationGenericParameter parameter = + readGenericParameter(entry.getValue(), imported, declaredPaths); + + if (parameter == null) { + return AttributeConfigurationImportPreview.failed(MALFORMED_GENERIC_PARAMETER); + } + + declared.put(entry.getKey(), parameter); + } + } + + AttributeMap targetAttributes = + draft.getAttributes() != null ? draft.getAttributes() : new AttributeMap(); + List importable = new ArrayList<>(); + List missing = new ArrayList<>(); + List typeMismatches = new ArrayList<>(); + + imported.forEach( + (name, configuration) -> { + Optional> target = targetAttributes.get(name); + + if (target.isEmpty()) { + missing.add(new AttributeConfigurationSummary(name, configuration.getType())); + return; + } + + String targetType = typeName(target.get()); + + if (!Objects.equals(configuration.getType(), targetType)) { + typeMismatches.add( + new AttributeConfigurationTypeMismatch(name, configuration.getType(), targetType)); + return; + } + + importable.add(new AttributeConfigurationSummary(name, configuration.getType())); + }); + + boolean assetTypeMismatch = !Objects.equals(assetType.asText(), draft.getType()); + + // Report what was skipped even when that leaves nothing to import + if (importable.isEmpty()) { + return new AttributeConfigurationImportPreview( + NO_IMPORTABLE_ATTRIBUTES, + assetType.asText(), + draft.getType(), + assetTypeMismatch, + importable, + missing, + typeMismatches, + Map.of(), + List.of(), + null); + } + + // Skipped attributes are not patched, so only parameters of importable ones need a value + Set importableNames = + importable.stream().map(AttributeConfigurationSummary::getName).collect(Collectors.toSet()); + Map required = new TreeMap<>(); + declared.forEach( + (name, parameter) -> { + List paths = + parameter.getPaths().stream() + .filter(path -> importableNames.contains(splitPath(path)[1])) + .toList(); + if (!paths.isEmpty()) { + required.put( + name, new AttributeConfigurationGenericParameter(parameter.getType(), paths)); + } + }); + + Map values = + genericParameterValues != null ? genericParameterValues : Map.of(); + + if (required.keySet().stream().anyMatch(name -> isAbsent(values.get(name)))) { + return new AttributeConfigurationImportPreview( + GENERIC_PARAMETER_VALUES_REQUIRED, + assetType.asText(), + draft.getType(), + assetTypeMismatch, + importable, + missing, + typeMismatches, + required, + List.of(), + null); + } + + List invalid = + required.entrySet().stream() + .filter(entry -> !isOfType(values.get(entry.getKey()), entry.getValue().getType())) + .map(Map.Entry::getKey) + .toList(); + + if (invalid.isEmpty()) { + required.forEach( + (name, parameter) -> + parameter + .getPaths() + .forEach( + path -> + resolveParent(imported, path) + .set(leafName(path), values.get(name).deepCopy()))); + invalid = findSchemaViolations(imported, required); + } + + if (!invalid.isEmpty()) { + return new AttributeConfigurationImportPreview( + INVALID_GENERIC_PARAMETER_VALUE, + assetType.asText(), + draft.getType(), + assetTypeMismatch, + importable, + missing, + typeMismatches, + required, + invalid, + null); + } + + ObjectNode patchedAttributes = toObjectNode(targetAttributes); + importable.forEach( + attribute -> + ((ObjectNode) patchedAttributes.get(attribute.getName())) + .set("meta", imported.get(attribute.getName()).getMeta())); + + return new AttributeConfigurationImportPreview( + null, + assetType.asText(), + draft.getType(), + assetTypeMismatch, + importable, + missing, + typeMismatches, + required, + List.of(), + patchedAttributes); + } + + /** + * Removes the values at the generic paths from the attributes and records them as generic + * parameters, keyed by a name that is unique within the document. + */ + protected static Map extractGenericParameters( + Map attributes, Collection genericPaths) { + Map parameters = new TreeMap<>(); + + if (genericPaths == null || genericPaths.isEmpty()) { + return parameters; + } + + // Find every candidate before removing any value, as the removal changes what is held + Map> candidates = findGenericPathCandidates(attributes); + + for (String path : new TreeSet<>(genericPaths)) { + List holders = candidates.get(path); + + if (holders == null) { + throw new IllegalArgumentException( + "Not a value that is equal in every exported attribute holding it: " + path); + } + + String[] segments = splitPath(path); + int leaf = segments.length - 1; + String type = null; + List paths = new ArrayList<>(); + + for (String holder : holders) { + ObjectNode parent = + (ObjectNode) resolve(attributes.get(holder).getMeta(), segments, 1, leaf); + type = scalarType(parent.remove(segments[leaf])); + paths.add(String.join(PATH_SEPARATOR, ATTRIBUTES_SEGMENT, holder, path)); + } + + String name = deriveGenericParameterName(path); + String uniqueName = name; + for (int i = 2; parameters.containsKey(uniqueName); i++) { + uniqueName = name + i; + } + + parameters.put(uniqueName, new AttributeConfigurationGenericParameter(type, paths)); + } + + return parameters; + } + + /** + * Reads a generic parameter declaration, or returns null if it is malformed. Each path must lead + * to a value of the document that is absent or a scalar, and may only be declared once. + */ + protected static AttributeConfigurationGenericParameter readGenericParameter( + JsonNode node, Map attributes, Set declaredPaths) { + JsonNode type = node.isObject() ? node.get("type") : null; + JsonNode paths = node.isObject() ? node.get("paths") : null; + + if (type == null || !type.isTextual() || !GENERIC_PARAMETER_TYPES.contains(type.asText())) { + return null; + } + if (paths == null || !paths.isArray() || paths.isEmpty()) { + return null; + } + + List result = new ArrayList<>(); + for (JsonNode path : paths) { + if (!path.isTextual() || !declaredPaths.add(path.asText())) { + return null; + } + + ObjectNode parent = resolveParent(attributes, path.asText()); + JsonNode value = parent != null ? parent.get(leafName(path.asText())) : null; + + if (parent == null || (value != null && value.isContainerNode())) { + return null; + } + + result.add(path.asText()); + } + + return new AttributeConfigurationGenericParameter(type.asText(), result); + } + + /** + * Names of the parameters applied to metadata that does not read as the value type of its meta + * item descriptor. Metadata without a descriptor has no schema to check against. + */ + protected static List findSchemaViolations( + Map attributes, + Map parameters) { + Set invalid = new TreeSet<>(); + + parameters.forEach( + (name, parameter) -> + parameter + .getPaths() + .forEach( + path -> { + String[] segments = splitPath(path); + JsonNode metaValue = attributes.get(segments[1]).getMeta().get(segments[3]); + + ValueUtil.getMetaItemDescriptor(segments[3]) + .ifPresent( + descriptor -> { + try { + ValueUtil.JSON.treeToValue( + metaValue, descriptor.getType().getType()); + } catch (JsonProcessingException | IllegalArgumentException e) { + invalid.add(name); + } + }); + })); + + return new ArrayList<>(invalid); + } + + /** + * The object holding the value at a document path of the form {@code + * attributes..meta.[....]}, or null if the path has another form + * or does not lead through objects of the given attributes. + */ + protected static ObjectNode resolveParent( + Map attributes, String path) { + String[] segments = splitPath(path); + + if (segments.length < 4 + || !ATTRIBUTES_SEGMENT.equals(segments[0]) + || !META_SEGMENT.equals(segments[2]) + || Arrays.stream(segments).anyMatch(String::isEmpty) + || !attributes.containsKey(segments[1])) { + return null; + } + + JsonNode parent = + resolve(attributes.get(segments[1]).getMeta(), segments, 3, segments.length - 1); + return parent != null && parent.isObject() ? (ObjectNode) parent : null; + } + + /** + * The node reached through the object properties named by the segments from the first index up to + * the second, or null. + */ + protected static JsonNode resolve(JsonNode node, String[] segments, int from, int to) { + for (int i = from; i < to && node != null; i++) { + node = node.isObject() ? node.get(segments[i]) : null; + } + return node; + } + + /** + * Calls the consumer with the path and value of each scalar reached through object properties. + */ + protected static void collectScalars( + JsonNode node, String path, BiConsumer consumer) { + if (node != null && node.isObject()) { + node.properties().stream() + .filter(property -> !property.getKey().isEmpty()) + .filter(property -> !property.getKey().contains(PATH_SEPARATOR)) + .forEach( + property -> + collectScalars( + property.getValue(), path + PATH_SEPARATOR + property.getKey(), consumer)); + } else if (scalarType(node) != null) { + consumer.accept(path, node); + } + } + + /** The generic parameter type of a JSON scalar, or null if the node is not one. */ + protected static String scalarType(JsonNode node) { + if (node == null) { + return null; + } + if (node.isTextual()) { + return ValueType.TEXT.getName(); + } + if (node.isBoolean()) { + return ValueType.BOOLEAN.getName(); + } + if (node.isIntegralNumber() && node.canConvertToInt()) { + return ValueType.INTEGER.getName(); + } + if (node.isIntegralNumber() && node.canConvertToLong()) { + return ValueType.LONG.getName(); + } + if (node.isNumber()) { + return ValueType.NUMBER.getName(); + } + return null; + } + + /** Whether a value fits a generic parameter type, a wider number type taking narrower ones. */ + protected static boolean isOfType(JsonNode value, String type) { + if (value == null) { + return false; + } + if (ValueType.NUMBER.getName().equals(type)) { + return value.isNumber(); + } + if (ValueType.LONG.getName().equals(type)) { + return value.isIntegralNumber() && value.canConvertToLong(); + } + return Objects.equals(type, scalarType(value)); + } + + protected static String[] splitPath(String path) { + return path.split(Pattern.quote(PATH_SEPARATOR), -1); + } + + protected static String leafName(String path) { + String[] segments = splitPath(path); + return segments[segments.length - 1]; + } + + protected static boolean isAbsent(JsonNode node) { + return node == null || node.isNull(); + } + + protected static String typeName(Attribute attribute) { + return attribute.getType() != null ? attribute.getType().getName() : null; + } + + /** + * Serialises through text rather than a token buffer, so that attribute values held as raw JSON + * are materialised into the returned tree. + */ + protected static ObjectNode toObjectNode(Object value) { + try { + return (ObjectNode) ValueUtil.JSON.readTree(ValueUtil.JSON.writeValueAsString(value)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot serialise attribute configuration", e); + } + } +} diff --git a/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy b/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy new file mode 100644 index 0000000000..278233f672 --- /dev/null +++ b/model/src/test/groovy/org/openremote/model/util/AttributeConfigurationUtilTest.groovy @@ -0,0 +1,445 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.model.util + +import com.fasterxml.jackson.databind.JsonNode +import org.openremote.model.asset.AttributeConfigurationDocument +import org.openremote.model.asset.impl.RoomAsset +import org.openremote.model.asset.impl.ThingAsset +import org.openremote.model.attribute.Attribute +import org.openremote.model.attribute.MetaItem +import org.openremote.model.value.MetaItemType +import org.openremote.model.value.ValueDescriptor +import org.openremote.model.value.ValueType +import spock.lang.Specification + +import static org.openremote.model.asset.AttributeConfigurationImportFailure.* + +class AttributeConfigurationUtilTest extends Specification { + + def setupSpec() { + ValueUtil.initialise(null) + } + + static def thingAsset(Map> attributes = [:]) { + def asset = new ThingAsset("Test Thing") + attributes.each { name, attribute -> asset.getAttributes().put(name, attribute) } + return asset + } + + static def attribute(String name, ValueDescriptor type, Map meta = null) { + def attribute = new Attribute<>(name, type) + meta?.each { metaName, value -> attribute.addMeta(new MetaItem<>(metaName, null, value)) } + return attribute + } + + static String document(String assetType = ThingAsset.DESCRIPTOR.name, Map entries = [:], Integer version = 1, String genericParameters = null) { + def attributes = entries.collect { name, entry -> "\"${name}\": ${entry}" }.join(",") + def fields = [] + if (version != null) { + fields.add("\"version\": ${version}") + } + if (assetType != null) { + fields.add("\"assetType\": \"${assetType}\"") + } + if (genericParameters != null) { + fields.add("\"genericParameters\": ${genericParameters}") + } + fields.add("\"attributes\": {${attributes}}") + return "{${fields.join(",")}}" + } + + static Map values(Map entries) { + return entries.collectEntries { name, value -> + [(name): ValueUtil.JSON.valueToTree(value)] + } as Map + } + + /** A pump whose attributes are linked to the same agent, each at another write value. */ + static def linkedPump() { + return thingAsset([ + pumpOn : attribute("pumpOn", ValueType.BOOLEAN, [ + (MetaItemType.LABEL.name) : "Pump on", + (MetaItemType.AGENT_LINK.name): [id: "agent1", type: "DefaultAgentLink", writeValue: "on", updateOnWrite: true] + ]), + error : attribute("error", ValueType.BOOLEAN, [ + (MetaItemType.LABEL.name) : "Error", + (MetaItemType.READ_ONLY.name) : true, + (MetaItemType.UNITS.name) : ["celsius"], + (MetaItemType.AGENT_LINK.name): [id: "agent1", type: "DefaultAgentLink", writeValue: "error", updateOnWrite: true] + ]), + plain : attribute("plain", ValueType.TEXT) + ]) + } + + def "export holds the type and metadata of attributes that have metadata"() { + given: "an asset with configured and unconfigured attributes" + def asset = thingAsset([ + temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature", unknownMetaItem: [nested: 1]]), + plain : attribute("plain", ValueType.TEXT) + ]) + + when: "the configuration is exported" + def exported = AttributeConfigurationUtil.export(asset, null) + + then: "the document is of the supported version and holds the asset type" + exported.version == AttributeConfigurationDocument.VERSION + exported.assetType == ThingAsset.DESCRIPTOR.name + + and: "only the attribute with metadata is held, with its type and its metadata as stored" + exported.attributes.keySet() == ["temperature"] as Set + exported.attributes.temperature.type == ValueType.NUMBER.name + exported.attributes.temperature.meta.get(MetaItemType.LABEL.name).asText() == "Temperature" + exported.attributes.temperature.meta.get("unknownMetaItem").get("nested").asInt() == 1 + } + + def "export limited to a selection holds only the selected attributes"() { + given: "an asset with two configured attributes" + def asset = thingAsset([ + temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"]), + humidity : attribute("humidity", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Humidity"]) + ]) + + expect: "the selection is exported" + AttributeConfigurationUtil.export(asset, ["humidity"]).attributes.keySet() == ["humidity"] as Set + } + + def "a document that cannot be read is rejected with #failure"() { + given: "an asset with a configured attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + expect: "the import is rejected" + AttributeConfigurationUtil.preview(asset, documentText).failure == failure + + where: + failure | documentText + INVALID_JSON | "not json at all" + INVALID_JSON | "[]" + INVALID_JSON | null + MISSING_REQUIRED_FIELD | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {}}'], null) + MISSING_REQUIRED_FIELD | document(null, [temperature: '{"type": "number", "meta": {}}']) + MISSING_REQUIRED_FIELD | '{"version": 1, "assetType": "ThingAsset"}' + UNSUPPORTED_VERSION | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {}}'], 2) + UNSUPPORTED_VERSION | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": {}}'], 0) + MALFORMED_ATTRIBUTE_ENTRY | '{"version": 1, "assetType": "ThingAsset", "attributes": 5}' + MALFORMED_ATTRIBUTE_ENTRY | document(ThingAsset.DESCRIPTOR.name, [temperature: '5']) + MALFORMED_ATTRIBUTE_ENTRY | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"meta": {}}']) + MALFORMED_ATTRIBUTE_ENTRY | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number"}']) + MALFORMED_ATTRIBUTE_ENTRY | document(ThingAsset.DESCRIPTOR.name, [temperature: '{"type": "number", "meta": 5}']) + NO_IMPORTABLE_ATTRIBUTES | document(ThingAsset.DESCRIPTOR.name, [:]) + } + + def "an attribute missing on the target asset is skipped and reported"() { + given: "an asset without the imported attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document holding a known and an unknown attribute is previewed" + def preview = AttributeConfigurationUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Imported"}}', + humidity : '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the unknown attribute is reported as missing and is not patched" + preview.failure == null + preview.missingAttributes*.name == ["humidity"] + preview.importableAttributes*.name == ["temperature"] + !preview.patchedAttributes.has("humidity") + } + + def "an attribute whose type differs is skipped and reported"() { + given: "an asset holding the imported attribute as text" + def asset = thingAsset([temperature: attribute("temperature", ValueType.TEXT, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document holding the attribute as a number is previewed" + def preview = AttributeConfigurationUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "nothing can be imported, with the mismatch reported" + preview.failure == NO_IMPORTABLE_ATTRIBUTES + preview.typeMismatches*.name == ["temperature"] + preview.typeMismatches*.importedType == [ValueType.NUMBER.name] + preview.typeMismatches*.targetType == [ValueType.TEXT.name] + } + + def "type comparison is case sensitive"() { + given: "an asset holding a number attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document spelling the same type differently is previewed" + def preview = AttributeConfigurationUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "Number", "meta": {"label": "Imported"}}', + humidity : '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the differently spelled type is a mismatch rather than a match" + preview.typeMismatches*.name == ["temperature"] + preview.typeMismatches*.importedType == ["Number"] + preview.typeMismatches*.targetType == [ValueType.NUMBER.name] + } + + def "a document exported from another asset type is reported but can still be imported"() { + given: "a thing asset holding a configured attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document exported from a room asset is previewed" + def preview = AttributeConfigurationUtil.preview(asset, document(RoomAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the mismatch is reported without preventing the import" + preview.failure == null + preview.assetTypeMismatch + preview.importedAssetType == RoomAsset.DESCRIPTOR.name + preview.targetAssetType == ThingAsset.DESCRIPTOR.name + preview.importableAttributes*.name == ["temperature"] + } + + def "the metadata of an importable attribute is replaced as a whole"() { + given: "an asset whose attribute holds metadata that the document does not" + def asset = thingAsset([ + temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature", (MetaItemType.READ_ONLY.name): true]), + plain : attribute("plain", ValueType.TEXT) + ]) + asset.getAttributes().get("temperature").get().setValue(21.5d) + + when: "a document holding other metadata for that attribute is previewed" + def preview = AttributeConfigurationUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"unitType": ["celsius"]}}', + plain : '{"type": "text", "meta": {"label": "Plain"}}' + ])) + + then: "the imported metadata replaces the existing metadata entirely" + def patchedTemperature = preview.patchedAttributes.get("temperature") + patchedTemperature.get("meta").get("unitType").get(0).asText() == "celsius" + !patchedTemperature.get("meta").has(MetaItemType.LABEL.name) + !patchedTemperature.get("meta").has(MetaItemType.READ_ONLY.name) + + and: "an attribute without metadata gets the imported metadata added" + preview.patchedAttributes.get("plain").get("meta").get("label").asText() == "Plain" + + and: "the type and the value of the attribute are untouched" + patchedTemperature.get("type").asText() == ValueType.NUMBER.name + patchedTemperature.get("value").asDouble() == 21.5d + + and: "the asset itself is not modified" + asset.getAttributes().get("temperature").get().getMeta().has(MetaItemType.LABEL.name) + !asset.getAttributes().get("plain").get().getMeta().has("label") + } + + def "an internal name field within an entry is ignored"() { + given: "an asset holding a configured attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER, [(MetaItemType.LABEL.name): "Temperature"])]) + + when: "a document whose entry names another attribute is previewed" + def preview = AttributeConfigurationUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"name": "somethingElse", "type": "number", "meta": {"label": "Imported"}}' + ])) + + then: "the map key decides which attribute is imported" + preview.importableAttributes*.name == ["temperature"] + preview.patchedAttributes.get("temperature").get("meta").get("label").asText() == "Imported" + } + + def "generic path candidates are the values held equally by every selected attribute holding them"() { + given: "a pump whose attributes share an agent but differ in label and write value" + def asset = linkedPump() + + when: "the candidates of all configured attributes are found" + def candidates = AttributeConfigurationUtil.findGenericPathCandidates(AttributeConfigurationUtil.export(asset, null).attributes) + + then: "shared values and values held by a single attribute are candidates, differing values and arrays are not" + candidates.keySet() as List == [ + "meta.agentLink.id", + "meta.agentLink.type", + "meta.agentLink.updateOnWrite", + "meta.readOnly" + ] + candidates["meta.agentLink.id"] == ["error", "pumpOn"] + candidates["meta.readOnly"] == ["error"] + + when: "the candidates of a single attribute are found" + def selected = AttributeConfigurationUtil.findGenericPathCandidates(AttributeConfigurationUtil.export(asset, ["pumpOn"]).attributes) + + then: "the value that differs from the unselected attribute is a candidate" + selected.containsKey("meta.agentLink.writeValue") + } + + def "a generic parameter name is derived from its path as #name"() { + expect: + AttributeConfigurationUtil.deriveGenericParameterName(path) == name + + where: + path | name + "meta.agentLink.id" | "agentLinkId" + "meta.agentLink.unitId" | "agentLinkUnitId" + "meta.label" | "label" + } + + def "export leaves a generic value out of every attribute holding it and records where it belongs"() { + given: "a pump whose attributes share an agent" + def asset = linkedPump() + + when: "the configuration is exported with the agent link id as a generic path" + def exported = AttributeConfigurationUtil.export(asset, null, ["meta.agentLink.id"]) + + then: "a single parameter named after the path records its type and both attribute paths" + exported.version == AttributeConfigurationDocument.VERSION + exported.genericParameters.keySet() == ["agentLinkId"] as Set + exported.genericParameters.agentLinkId.type == ValueType.TEXT.name + exported.genericParameters.agentLinkId.paths == ["attributes.error.meta.agentLink.id", "attributes.pumpOn.meta.agentLink.id"] + + and: "the value is removed from both attributes while the rest of their metadata is kept" + !exported.attributes.error.meta.get("agentLink").has("id") + !exported.attributes.pumpOn.meta.get("agentLink").has("id") + exported.attributes.error.meta.get("agentLink").get("writeValue").asText() == "error" + exported.attributes.pumpOn.meta.get(MetaItemType.LABEL.name).asText() == "Pump on" + + and: "the asset itself keeps the value, so exporting without generic paths still holds it" + def plain = AttributeConfigurationUtil.export(asset, null, []) + plain.genericParameters == null + plain.attributes.error.meta.get("agentLink").get("id").asText() == "agent1" + } + + def "export rejects the generic path #path"() { + given: "a pump whose attributes share an agent but differ in write value" + def asset = linkedPump() + + when: "the configuration is exported with a path that cannot be generic" + AttributeConfigurationUtil.export(asset, null, [path]) + + then: "the export is rejected" + thrown(IllegalArgumentException) + + where: + path << [ + "meta.agentLink.writeValue", + "meta.units", + "meta.agentLink", + "meta.absent", + "agentLink.id" + ] + } + + def "a generic parameter is asked for once and applied to every attribute holding it"() { + given: "a document exported with the agent link id of two attributes as a generic parameter" + def document = ValueUtil.asJSON(AttributeConfigurationUtil.export(linkedPump(), null, ["meta.agentLink.id"])).orElseThrow() + + and: "a pump that is not linked yet" + def target = thingAsset([ + pumpOn: attribute("pumpOn", ValueType.BOOLEAN), + error : attribute("error", ValueType.BOOLEAN) + ]) + + when: "the document is previewed without a value" + def preview = AttributeConfigurationUtil.preview(target, document) + + then: "the single parameter is reported with both attribute paths" + preview.failure == GENERIC_PARAMETER_VALUES_REQUIRED + preview.importableAttributes*.name == ["error", "pumpOn"] + preview.genericParameters.keySet() == ["agentLinkId"] as Set + preview.genericParameters.agentLinkId.type == ValueType.TEXT.name + preview.genericParameters.agentLinkId.paths == ["attributes.error.meta.agentLink.id", "attributes.pumpOn.meta.agentLink.id"] + preview.patchedAttributes == null + + when: "the document is previewed with a value for the parameter" + preview = AttributeConfigurationUtil.preview(target, document, values([agentLinkId: "agent2"])) + + then: "the value is applied to both attributes, next to the metadata that was exported" + preview.failure == null + preview.patchedAttributes.get("error").get("meta").get("agentLink").get("id").asText() == "agent2" + preview.patchedAttributes.get("pumpOn").get("meta").get("agentLink").get("id").asText() == "agent2" + preview.patchedAttributes.get("pumpOn").get("meta").get("agentLink").get("writeValue").asText() == "on" + } + + def "a generic parameter held only by skipped attributes needs no value"() { + given: "a document with a generic parameter held by the error attribute alone" + def document = ValueUtil.asJSON(AttributeConfigurationUtil.export(linkedPump(), null, ["meta.readOnly"])).orElseThrow() + + and: "a pump without that attribute" + def target = thingAsset([pumpOn: attribute("pumpOn", ValueType.BOOLEAN)]) + + when: "the document is previewed without a value" + def preview = AttributeConfigurationUtil.preview(target, document) + + then: "the remaining attribute is imported without asking for the parameter" + preview.failure == null + preview.missingAttributes*.name == ["error"] + preview.genericParameters.isEmpty() + preview.patchedAttributes.get("pumpOn").get("meta").get(MetaItemType.LABEL.name).asText() == "Pump on" + } + + def "a generic parameter value is checked against the type #type and the metadata schema"() { + given: "an asset holding a linked number attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER)]) + + and: "a document with a generic parameter for one of its metadata values" + def documentText = document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"readOnly": true, "agentLink": {"id": "agent1", "type": "DefaultAgentLink"}}}' + ], 1, "{\"parameter\": {\"type\": \"${type}\", \"paths\": [\"attributes.temperature.meta.${path}\"]}}") + + when: "the document is previewed with the value" + def preview = AttributeConfigurationUtil.preview(asset, documentText, values([parameter: value])) + + then: "the value is accepted only when it fits" + preview.failure == (valid ? null : INVALID_GENERIC_PARAMETER_VALUE) + preview.invalidGenericParameters == (valid ? [] : ["parameter"]) + + where: + type | path | value | valid + "text" | "agentLink.id" | "agent2" | true + "text" | "agentLink.id" | 5 | false + "boolean" | "readOnly" | false | true + "boolean" | "readOnly" | "yes" | false + "integer" | "custom" | 5 | true + "integer" | "custom" | 1.5 | false + "long" | "custom" | 5 | true + "number" | "custom" | 5 | true + "text" | "readOnly" | "notABoolean" | false + "text" | "agentLink.updateOnWrite" | "sometimes" | false + } + + def "a malformed generic parameter declaration is rejected"() { + given: "an asset holding a number attribute" + def asset = thingAsset([temperature: attribute("temperature", ValueType.NUMBER)]) + + expect: "the import is rejected" + AttributeConfigurationUtil.preview(asset, document(ThingAsset.DESCRIPTOR.name, [ + temperature: '{"type": "number", "meta": {"label": "Temperature", "agentLink": {"id": "agent1", "type": "DefaultAgentLink"}}}' + ], 1, genericParameters), values([p: "value", q: "value"])).failure == MALFORMED_GENERIC_PARAMETER + + where: + genericParameters << [ + '5', + '{"p": 5}', + '{"p": {"paths": ["attributes.temperature.meta.label"]}}', + '{"p": {"type": "color", "paths": ["attributes.temperature.meta.label"]}}', + '{"p": {"type": "text"}}', + '{"p": {"type": "text", "paths": []}}', + '{"p": {"type": "text", "paths": [5]}}', + '{"p": {"type": "text", "paths": ["temperature.meta.label"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.label"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.meta.label."]}}', + '{"p": {"type": "text", "paths": ["attributes.absent.meta.label"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.meta.missing.id"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.meta.agentLink"]}}', + '{"p": {"type": "text", "paths": ["attributes.temperature.meta.label"]}, "q": {"type": "text", "paths": ["attributes.temperature.meta.label"]}}' + ] + } +} diff --git a/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy new file mode 100644 index 0000000000..e04457590d --- /dev/null +++ b/test/src/test/groovy/org/openremote/test/assets/AttributeConfigurationTest.groovy @@ -0,0 +1,258 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.openremote.test.assets + +import com.fasterxml.jackson.databind.node.BooleanNode +import jakarta.ws.rs.ForbiddenException +import jakarta.ws.rs.WebApplicationException +import org.openremote.manager.setup.SetupService +import org.openremote.model.asset.AssetResource +import org.openremote.model.asset.AttributeConfigurationImportPreview +import org.openremote.model.asset.AttributeConfigurationImportRequest +import org.openremote.model.asset.impl.ThingAsset +import org.openremote.model.attribute.Attribute +import org.openremote.model.attribute.MetaItem +import org.openremote.model.util.ValueUtil +import org.openremote.model.value.MetaItemType +import org.openremote.model.value.ValueType +import org.openremote.setup.integration.KeycloakTestSetup +import org.openremote.setup.integration.ManagerTestSetup +import org.openremote.test.ManagerContainerTrait +import spock.lang.Specification + +import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST +import static jakarta.ws.rs.core.Response.Status.CONFLICT +import static jakarta.ws.rs.core.Response.Status.FORBIDDEN +import static org.openremote.manager.security.ManagerIdentityProvider.OR_ADMIN_PASSWORD +import static org.openremote.manager.security.ManagerIdentityProvider.OR_ADMIN_PASSWORD_DEFAULT +import static org.openremote.model.Constants.* +import static org.openremote.model.asset.AttributeConfigurationImportFailure.* +import static org.openremote.model.util.MapAccess.getString + +class AttributeConfigurationTest extends Specification implements ManagerContainerTrait { + + def "Export and import attribute configuration as superuser"() { + given: "the server container is started" + def container = startContainer(defaultConfig(), defaultServices()) + def keycloakTestSetup = container.getService(SetupService.class).getTaskOfType(KeycloakTestSetup.class) + + and: "an authenticated admin user" + def accessToken = authenticate( + container, + MASTER_REALM, + KEYCLOAK_CLIENT_ID, + MASTER_REALM_ADMIN_USER, + getString(container.getConfig(), OR_ADMIN_PASSWORD, OR_ADMIN_PASSWORD_DEFAULT) + ) + + and: "the asset resource" + def assetResource = getClientApiTarget(serverUri(serverPort), MASTER_REALM, accessToken).proxy(AssetResource.class) + + and: "a configured source asset and a target asset of the same type" + def source = assetResource.create(null, new ThingAsset("Configuration source") + .setRealm(keycloakTestSetup.realmMaster.name) + .addOrReplaceAttributes( + new Attribute<>("temperature", ValueType.NUMBER) + .addMeta(new MetaItem<>(MetaItemType.LABEL, "Source temperature")) + .addMeta(new MetaItem<>(MetaItemType.READ_ONLY, true)), + new Attribute<>("customValue", ValueType.TEXT) + .addMeta(new MetaItem<>(MetaItemType.LABEL, "Source custom value")), + new Attribute<>("plain", ValueType.TEXT) + )) + def target = assetResource.create(null, new ThingAsset("Configuration target") + .setRealm(keycloakTestSetup.realmMaster.name) + .addOrReplaceAttributes( + new Attribute<>("temperature", ValueType.NUMBER, 21.5d) + .addMeta(new MetaItem<>(MetaItemType.LABEL, "Target temperature")), + new Attribute<>("customValue", ValueType.NUMBER), + new Attribute<>("plain", ValueType.TEXT) + )) + + when: "the attribute configuration of the source asset is exported" + def document = assetResource.exportAttributeConfiguration(null, source.id, [], []) + + then: "only the attributes holding metadata are exported, with their type and metadata" + document.version == 1 + document.assetType == ThingAsset.DESCRIPTOR.name + document.attributes.keySet() == ["temperature", "customValue"] as Set + document.attributes.temperature.type == ValueType.NUMBER.name + document.attributes.temperature.meta.get(MetaItemType.LABEL.name).asText() == "Source temperature" + document.attributes.temperature.meta.get(MetaItemType.READ_ONLY.name).asBoolean() + + when: "a subset of the attributes is exported" + def subset = assetResource.exportAttributeConfiguration(null, source.id, ["temperature"], []) + + then: "only the requested attribute is exported" + subset.attributes.keySet() == ["temperature"] as Set + + when: "the exported document is imported into the target asset" + def preview = assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(target, ValueUtil.asJSON(document).orElseThrow())) + + then: "the attribute with a matching type is importable and the one with another type is reported" + preview.failure == null + !preview.assetTypeMismatch + preview.importableAttributes*.name == ["temperature"] + preview.typeMismatches*.name == ["customValue"] + preview.typeMismatches*.importedType == [ValueType.TEXT.name] + preview.typeMismatches*.targetType == [ValueType.NUMBER.name] + + and: "the patched attributes hold the imported metadata, keeping the attribute value" + def patched = preview.patchedAttributes.get("temperature") + patched.get("meta").get(MetaItemType.LABEL.name).asText() == "Source temperature" + patched.get("meta").get(MetaItemType.READ_ONLY.name).asBoolean() + patched.get("value").asDouble() == 21.5d + + and: "the attributes that are not imported are returned untouched" + preview.patchedAttributes.get("customValue").get("type").asText() == ValueType.NUMBER.name + + and: "the target asset is not stored by the preview" + assetResource.get(null, target.id).getAttribute("temperature").get().getMeta() + .getValue(MetaItemType.LABEL.name, String.class).orElse(null) == "Target temperature" + + when: "a document of an unsupported version is imported" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(target, '{"version": 2, "assetType": "ThingAsset", "attributes": {}}')) + + then: "the import is rejected with the reason" + WebApplicationException ex = thrown() + ex.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + assert r.readEntity(AttributeConfigurationImportPreview.class).failure == UNSUPPORTED_VERSION + return true + } + + when: "a document holding no attribute of the target asset is imported" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(target, '{"version": 1, "assetType": "ThingAsset", "attributes": {"absent": {"type": "text", "meta": {"label": "Absent"}}}}')) + + then: "the import is rejected and the skipped attribute is reported" + WebApplicationException noneImportable = thrown() + noneImportable.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + def rejected = r.readEntity(AttributeConfigurationImportPreview.class) + assert rejected.failure == NO_IMPORTABLE_ATTRIBUTES + assert rejected.missingAttributes*.name == ["absent"] + return true + } + + when: "the draft of another asset is submitted" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(source, ValueUtil.asJSON(document).orElseThrow())) + + then: "the import conflicts with the target asset" + WebApplicationException draftMismatch = thrown() + draftMismatch.response.withCloseable { r -> + assert r.status == CONFLICT.statusCode + assert r.readEntity(AttributeConfigurationImportPreview.class).failure == DRAFT_MISMATCH + return true + } + + when: "no draft is submitted" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(null, ValueUtil.asJSON(document).orElseThrow())) + + then: "the request is rejected as invalid rather than as a conflict" + WebApplicationException noDraft = thrown() + noDraft.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + return true + } + + when: "the read only flag of the source asset is exported as a generic parameter" + def generic = assetResource.exportAttributeConfiguration(null, source.id, [], ["meta.readOnly"]) + + then: "the flag is recorded as a parameter instead of a value" + generic.genericParameters.readOnly.type == ValueType.BOOLEAN.name + generic.genericParameters.readOnly.paths == ["attributes.temperature.meta.readOnly"] + !generic.attributes.temperature.meta.has(MetaItemType.READ_ONLY.name) + + when: "the document is imported without a value for the parameter" + assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(target, ValueUtil.asJSON(generic).orElseThrow())) + + then: "the import asks for the parameter" + WebApplicationException valuesRequired = thrown() + valuesRequired.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + def report = r.readEntity(AttributeConfigurationImportPreview.class) + assert report.failure == GENERIC_PARAMETER_VALUES_REQUIRED + assert report.genericParameters.keySet() == ["readOnly"] as Set + return true + } + + when: "the document is imported with a value for the parameter" + def withValue = assetResource.previewAttributeConfigurationImport(null, target.id, + new AttributeConfigurationImportRequest(target, ValueUtil.asJSON(generic).orElseThrow(), [readOnly: BooleanNode.FALSE])) + + then: "the value is applied to the imported metadata" + withValue.failure == null + !withValue.patchedAttributes.get("temperature").get("meta").get(MetaItemType.READ_ONLY.name).asBoolean() + + when: "a value that differs between the exported attributes is requested as a generic parameter" + assetResource.exportAttributeConfiguration(null, source.id, [], ["meta.label"]) + + then: "the export is rejected" + WebApplicationException notGeneric = thrown() + notGeneric.response.withCloseable { r -> + assert r.status == BAD_REQUEST.statusCode + return true + } + } + + def "Attribute configuration import requires asset write access"() { + given: "the server container is started" + def container = startContainer(defaultConfig(), defaultServices()) + def managerTestSetup = container.getService(SetupService.class).getTaskOfType(ManagerTestSetup.class) + def keycloakTestSetup = container.getService(SetupService.class).getTaskOfType(KeycloakTestSetup.class) + + and: "an authenticated user that can read but not write assets" + def accessToken = authenticate( + container, + keycloakTestSetup.realmBuilding.name, + KEYCLOAK_CLIENT_ID, + "testuser2", + "testuser2" + ) + + and: "the asset resource" + def assetResource = getClientApiTarget(serverUri(serverPort), keycloakTestSetup.realmBuilding.name, accessToken) + .proxy(AssetResource.class) + + when: "the attribute configuration of an asset of the authenticated realm is exported" + def document = assetResource.exportAttributeConfiguration(null, managerTestSetup.apartment1LivingroomId, [], []) + + then: "the configuration is returned" + document.version == 1 + !document.attributes.isEmpty() + + when: "the same user previews an import" + assetResource.previewAttributeConfigurationImport(null, managerTestSetup.apartment1LivingroomId, + new AttributeConfigurationImportRequest( + assetResource.get(null, managerTestSetup.apartment1LivingroomId), + ValueUtil.asJSON(document).orElseThrow())) + + then: "access is forbidden" + ForbiddenException forbidden = thrown() + forbidden.response.withCloseable { r -> + assert r.status == FORBIDDEN.statusCode + return true + } + } +} diff --git a/ui/app/shared/locales/en/or.json b/ui/app/shared/locales/en/or.json index e87d0da42e..8f7a4959e5 100644 --- a/ui/app/shared/locales/en/or.json +++ b/ui/app/shared/locales/en/or.json @@ -320,6 +320,7 @@ "linkedUsers": "Linked users", "setup": "Setup", "selectFile": "Select file", + "selectAgent": "Select an agent", "file": "File", "discoverAssets": "Discover assets", "discovering": "Discovering", @@ -1332,5 +1333,43 @@ } } } + }, + "attributeConfiguration": { + "import": "Import", + "export": "Export", + "exportHeading": "Export configuration template", + "exportDescription": "You can save all the configured attributes and their configuration items as a template, this allows you to reproduce this asset easily", + "selectAttributes": "Save these attributes:", + "nothingToExport": "None of the attributes of this asset have configuration items to export", + "saveBeforeExport": "Save or revert your changes before exporting", + "importHeading": "Import configuration template", + "selectFileDescription": "Select a JSON file to import attribute configuration data", + "noFileSelected": "no file selected", + "overwriteWarning": "You are about to import the following attribute configuration data. This will overwrite existing metadata for compatible attributes in your current draft.", + "assetTypeMismatch": "This configuration was exported from a {{imported}}, while this asset is a {{target}}.", + "skippedMissing": "Skipped, not an attribute of this asset", + "skippedTypeMismatch": "Skipped, the attribute has a different type", + "next": "Next", + "warningHeading": "Warning", + "overwriteConfirm": "You are about to overwrite existing attribute configuration, are you sure?", + "confirm": "Confirm", + "imported": "The configuration of these attributes was imported: {{attributes}}", + "saveToPersist": "Save the asset to keep these changes, or discard them as usual.", + "genericParameters": "Included generic parameters:", + "genericParametersDescription": "Selected values are left out of the file and asked for once when it is imported.", + "genericParameterValuesDescription": "This configuration needs a value for the following parameters. Each value is applied to every attribute listed with it.", + "invalidGenericParameterValue": "This value does not fit the configuration it is applied to.", + "forbidden": "You are not allowed to import attribute configuration into this asset.", + "failure": { + "INVALID_JSON": "The selected file does not hold valid JSON.", + "UNSUPPORTED_VERSION": "The selected file holds an unsupported attribute configuration version.", + "MISSING_REQUIRED_FIELD": "The selected file is missing a required field.", + "MALFORMED_ATTRIBUTE_ENTRY": "The selected file holds a malformed attribute entry.", + "MALFORMED_GENERIC_PARAMETER": "The selected file holds a malformed generic parameter.", + "NO_IMPORTABLE_ATTRIBUTES": "None of the attributes in the selected file can be imported into this asset.", + "GENERIC_PARAMETER_VALUES_REQUIRED": "The selected file needs a value for each of its generic parameters.", + "INVALID_GENERIC_PARAMETER_VALUE": "A generic parameter value does not fit the configuration it is applied to.", + "DRAFT_MISMATCH": "This asset has changed since you opened it; refresh it and try again." + } } } diff --git a/ui/app/shared/locales/nl/or.json b/ui/app/shared/locales/nl/or.json index 01f9b9a7f9..6fe846c3b4 100644 --- a/ui/app/shared/locales/nl/or.json +++ b/ui/app/shared/locales/nl/or.json @@ -315,6 +315,7 @@ "linkedUsers": "Gekoppelde gebruikers", "setup": "Instellingen", "selectFile": "Kies bestand", + "selectAgent": "Selecteer een agent", "file": "Bestand", "discoverAssets": "Haal assets op", "discovering": "Assets ophalen", @@ -1318,5 +1319,43 @@ } } } + }, + "attributeConfiguration": { + "import": "Importeren", + "export": "Exporteren", + "exportHeading": "Configuratiesjabloon exporteren", + "exportDescription": "Je kunt alle geconfigureerde attributen en hun configuratie-items als sjabloon opslaan, zodat je dit asset eenvoudig kunt reproduceren", + "selectAttributes": "Sla deze attributen op:", + "nothingToExport": "Geen van de attributen van dit asset heeft configuratie-items om te exporteren", + "saveBeforeExport": "Sla je wijzigingen op of maak ze ongedaan voordat je exporteert", + "importHeading": "Configuratiesjabloon importeren", + "selectFileDescription": "Selecteer een JSON-bestand om attribuutconfiguratie te importeren", + "noFileSelected": "geen bestand geselecteerd", + "overwriteWarning": "Je staat op het punt de volgende attribuutconfiguratie te importeren. Dit overschrijft de bestaande metadata van compatibele attributen in je huidige concept.", + "assetTypeMismatch": "Deze configuratie is geëxporteerd vanaf een {{imported}}, terwijl dit asset een {{target}} is.", + "skippedMissing": "Overgeslagen, geen attribuut van dit asset", + "skippedTypeMismatch": "Overgeslagen, het attribuut heeft een ander type", + "next": "Volgende", + "warningHeading": "Waarschuwing", + "overwriteConfirm": "Je staat op het punt bestaande attribuutconfiguratie te overschrijven, weet je het zeker?", + "confirm": "Bevestigen", + "imported": "De configuratie van deze attributen is geïmporteerd: {{attributes}}", + "saveToPersist": "Sla het asset op om deze wijzigingen te behouden, of maak ze zoals gebruikelijk ongedaan.", + "genericParameters": "Opgenomen generieke parameters:", + "genericParametersDescription": "Geselecteerde waarden worden uit het bestand weggelaten en bij het importeren eenmalig gevraagd.", + "genericParameterValuesDescription": "Deze configuratie heeft een waarde nodig voor de volgende parameters. Elke waarde wordt toegepast op alle attributen die erbij vermeld staan.", + "invalidGenericParameterValue": "Deze waarde past niet bij de configuratie waarop ze wordt toegepast.", + "forbidden": "Je hebt geen rechten om attribuutconfiguratie in dit asset te importeren.", + "failure": { + "INVALID_JSON": "Het geselecteerde bestand bevat geen geldige JSON.", + "UNSUPPORTED_VERSION": "Het geselecteerde bestand bevat een niet-ondersteunde versie van de attribuutconfiguratie.", + "MISSING_REQUIRED_FIELD": "In het geselecteerde bestand ontbreekt een verplicht veld.", + "MALFORMED_ATTRIBUTE_ENTRY": "Het geselecteerde bestand bevat een ongeldige attribuutvermelding.", + "MALFORMED_GENERIC_PARAMETER": "Het geselecteerde bestand bevat een ongeldige generieke parameter.", + "NO_IMPORTABLE_ATTRIBUTES": "Geen van de attributen in het geselecteerde bestand kan in dit asset worden geïmporteerd.", + "GENERIC_PARAMETER_VALUES_REQUIRED": "Het geselecteerde bestand heeft een waarde nodig voor elk van zijn generieke parameters.", + "INVALID_GENERIC_PARAMETER_VALUE": "Een waarde van een generieke parameter past niet bij de configuratie waarop ze wordt toegepast.", + "DRAFT_MISMATCH": "Dit asset is gewijzigd sinds je het opende; ververs het en probeer het opnieuw." + } } } diff --git a/ui/component/or-asset-viewer/src/attribute-configuration.ts b/ui/component/or-asset-viewer/src/attribute-configuration.ts new file mode 100644 index 0000000000..ad47c2f345 --- /dev/null +++ b/ui/component/or-asset-viewer/src/attribute-configuration.ts @@ -0,0 +1,998 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { html, type TemplateResult } from "lit"; +import { createRef, ref, type Ref } from "lit/directives/ref.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { live } from "lit/directives/live.js"; +import { repeat } from "lit/directives/repeat.js"; +import { until } from "lit/directives/until.js"; +import { when } from "lit/directives/when.js"; +import manager, { Util } from "@openremote/core"; +import { + AssetModelUtil, + AttributeConfigurationImportFailure, + WellknownMetaItems, + WellknownValueTypes, + type Asset, + type Attribute, + type AttributeConfigurationGenericParameter, + type AttributeConfigurationImportPreview, +} from "@openremote/model"; +import { getAgentOptions, loadAgents } from "@openremote/or-attribute-input"; +import { i18next } from "@openremote/or-translate"; +import "@openremote/or-icon"; +import "@openremote/or-translate"; +import type { OrIcon } from "@openremote/or-icon"; +import "@openremote/or-vaadin-components/or-vaadin-button"; +import "@openremote/or-vaadin-components/or-vaadin-checkbox"; +import "@openremote/or-vaadin-components/or-vaadin-dialog"; +import "@openremote/or-vaadin-components/or-vaadin-number-field"; +import "@openremote/or-vaadin-components/or-vaadin-select"; +import "@openremote/or-vaadin-components/or-vaadin-text-field"; +import type { OrVaadinButton } from "@openremote/or-vaadin-components/or-vaadin-button"; +import type { OrVaadinCheckbox } from "@openremote/or-vaadin-components/or-vaadin-checkbox"; +import { + dialogFooterRenderer, + dialogHeaderRenderer, + dialogRenderer, + showDialog, +} from "@openremote/or-vaadin-components/or-vaadin-dialog"; +import { + getConfirmDialogContent, + showConfirmDialog, + showErrorDialog, + type OrVaadinConfirmDialog, +} from "@openremote/or-vaadin-components/or-vaadin-confirm-dialog"; +import { showSnackbar } from "@openremote/or-mwc-components/or-mwc-snackbar"; + +export type AssetAttributes = { [index: string]: Attribute }; + +/** A metadata value that can be left out of an export, for the user to supply on import. */ +export interface GenericPathCandidate { + /** Path relative to an attribute, e.g. meta.agentLink.id. */ + path: string; + value: string | number | boolean; + attributes: Attribute[]; +} + +type GenericParameterValues = { [name: string]: unknown }; + +const DIALOG_WIDTH = "550px"; + +// language=CSS +const dialogStyle = html` + +`; + +/** The attributes that hold metadata, which are the attributes that can be exported. */ +export function getConfigurableAttributes(asset: Asset): Attribute[] { + return Object.entries(asset.attributes || {}) + .filter(([, attribute]) => Object.keys(attribute.meta || {}).length > 0) + .map(([name, attribute]) => ({ ...attribute, name })) + .sort(Util.sortByString((attribute) => attribute.name!.toUpperCase())); +} + +/** Lets the user pick the attributes to export, then downloads the configuration document. */ +export function showExportAttributeConfigurationDialog(host: Node, asset: Asset) { + const attributes = getConfigurableAttributes(asset); + + if (attributes.length === 0) { + showSnackbar(undefined, "attributeConfiguration.nothingToExport"); + return; + } + + const selected = new Set(attributes.map((attribute) => attribute.name!)); + const genericPaths = new Set(); + let candidates = getGenericPathCandidates(attributes); + const exportBtn: Ref = createRef(); + + const onToggle = (name: string, checked: boolean) => { + if (checked) { + selected.add(name); + } else { + selected.delete(name); + } + if (exportBtn.value) { + exportBtn.value.disabled = selected.size === 0; + } + + // A value only stays a candidate while the selected attributes holding it agree on it + candidates = getGenericPathCandidates(attributes.filter((attribute) => selected.has(attribute.name!))); + genericPaths.forEach((path) => { + if (!candidates.some((candidate) => candidate.path === path)) { + genericPaths.delete(path); + } + }); + dialog?.requestContentUpdate(); + }; + + const onToggleGeneric = (path: string, checked: boolean) => { + if (checked) { + genericPaths.add(path); + } else { + genericPaths.delete(path); + } + }; + + const dialog = showDialog( + host, + html` + getHeaderTemplate("attributeConfiguration.exportHeading", () => dialog?.close()))} + ${dialogRenderer( + () => html` + ${dialogStyle} +
+ +
+ +
+ ${attributes.map( + (attribute) => html` +
+
+ + onToggle(attribute.name!, (ev.currentTarget as OrVaadinCheckbox).checked)} + > + ${Object.keys(attribute.meta || {}).length} + + + +
+
+ ${metaLabels(asset, attribute).map((label) => html`${label}`)} +
+
+ ` + )} +
+
+ ${when( + candidates.length > 0, + () => html` +
+ + +
+ ${repeat( + candidates, + (candidate) => candidate.path, + (candidate) => html` +
+
+ + onToggleGeneric(candidate.path, (ev.currentTarget as OrVaadinCheckbox).checked)} + > + ${candidate.attributes.length} + + + +
+
+ ${candidate.path}: ${String(candidate.value)} + ${candidate.attributes.map( + (attribute) => html`${attributeLabel(asset, attribute)}` + )} +
+
+ ` + )} +
+
+ ` + )} +
+ ` + )} + ${dialogFooterRenderer( + () => html` +
+ dialog?.close()}> + + + { + dialog?.close(); + downloadAttributeConfiguration(asset, Array.from(selected), Array.from(genericPaths)); + }} + > + + +
+ ` + )} + >
+ ` + ); +} + +/** + * Lets the user pick a configuration file, reports what importing it would do and, once confirmed, + * hands the patched attributes to the caller to apply to the asset draft. + */ +export function showImportAttributeConfigurationDialog( + host: Node, + asset: Asset, + onImported: (attributes: AssetAttributes) => void +) { + let file: File | undefined; + const fileName: Ref = createRef(); + const fileInput: Ref = createRef(); + const importBtn: Ref = createRef(); + + const onFileSelected = () => { + file = fileInput.value?.files?.[0]; + if (fileName.value) { + fileName.value.textContent = file ? file.name : i18next.t("attributeConfiguration.noFileSelected"); + fileName.value.classList.toggle("ac-placeholder", !file); + } + if (importBtn.value) { + importBtn.value.disabled = !file; + } + }; + + const onImport = async () => { + if (!file) { + return; + } + + const configuration = await file.text(); + const selectedName = file.name; + dialog?.close(); + + previewImport(host, asset, selectedName, configuration, undefined, onImported); + }; + + const dialog = showDialog( + host, + html` + getHeaderTemplate("attributeConfiguration.importHeading", () => dialog?.close()))} + ${dialogRenderer( + () => html` + ${dialogStyle} +
+
+ +
+ onFileSelected()} + /> + fileInput.value?.click()}> + + + + ${i18next.t("attributeConfiguration.noFileSelected")} + +
+
+
+ ` + )} + ${dialogFooterRenderer( + () => html` +
+ dialog?.close()}> + + + onImport()}> + + +
+ ` + )} + >
+ ` + ); +} + +/** + * Asks the server what importing the configuration would do, first asking the user for any generic + * parameter values it needs. + */ +async function previewImport( + host: Node, + asset: Asset, + fileName: string, + configuration: string, + values: GenericParameterValues | undefined, + onImported: (attributes: AssetAttributes) => void +) { + try { + const response = await manager.rest.api.AssetResource.previewAttributeConfigurationImport(asset.id!, { + draft: asset, + document: configuration, + genericParameterValues: values, + }); + showImportPreviewDialog(host, asset, fileName, response.data, onImported); + } catch (error) { + const preview = getFailurePreview(error); + + if ( + preview?.failure === AttributeConfigurationImportFailure.GENERIC_PARAMETER_VALUES_REQUIRED || + preview?.failure === AttributeConfigurationImportFailure.INVALID_GENERIC_PARAMETER_VALUE + ) { + showGenericParametersDialog(host, asset, fileName, preview, values || {}, (submitted) => + previewImport(host, asset, fileName, configuration, submitted, onImported) + ); + } else { + showImportFailureDialog(host, error); + } + } +} + +/** Asks once for the value of each generic parameter, which the import applies to all its paths. */ +function showGenericParametersDialog( + host: Node, + asset: Asset, + fileName: string, + preview: AttributeConfigurationImportPreview, + previousValues: GenericParameterValues, + onSubmit: (values: GenericParameterValues) => void +) { + const parameters = Object.entries(preview.genericParameters || {}); + const invalid = new Set(preview.invalidGenericParameters || []); + const values: GenericParameterValues = { ...previousValues }; + const nextBtn: Ref = createRef(); + + parameters + .filter(([name, parameter]) => parameter.type === WellknownValueTypes.BOOLEAN && values[name] === undefined) + .forEach(([name]) => (values[name] = false)); + + const isComplete = () => + parameters.every(([name, parameter]) => isGenericParameterValue(values[name], parameter.type)); + + const onChange = (name: string, value: unknown) => { + values[name] = value; + if (nextBtn.value) { + nextBtn.value.disabled = !isComplete(); + } + }; + + const dialog = showDialog( + host, + html` + getHeaderTemplate("attributeConfiguration.importHeading", () => dialog?.close()))} + ${dialogRenderer( + () => html` + ${dialogStyle} +
+ ${fileName} +
+ +
+ ${parameters.map( + ([name, parameter]) => html` +
+
+ ${getGenericParameterInputTemplate(name, parameter, values[name], (value) => + onChange(name, value) + )} + ${parameter.paths?.length} + + + +
+ ${when( + invalid.has(name), + () => html` + + ` + )} +
+ ${(parameter.paths || []) + .map((path) => path.split(".")[1]) + .map( + (attributeName) => + html`${attributeLabel(asset, asset.attributes?.[attributeName], attributeName)}` + )} +
+
+ ` + )} +
+
+
+ ` + )} + ${dialogFooterRenderer( + () => html` +
+ dialog?.close()}> + + + { + dialog?.close(); + onSubmit(values); + }} + > + + +
+ ` + )} + >
+ ` + ); +} + +/** The input for a generic parameter value, an agent picker when it is the id of agent links. */ +function getGenericParameterInputTemplate( + name: string, + parameter: AttributeConfigurationGenericParameter, + value: unknown, + onChange: (value: unknown) => void +): TemplateResult { + if (isAgentLinkIdParameter(parameter)) { + return html`${until( + loadAgents().then( + (agents) => html` + ({ value: id, label }))} + .value=${(value as string | undefined) ?? ""} + @value-changed=${(ev: CustomEvent<{ value: string }>) => onChange(ev.detail.value)} + > + ` + ), + html`` + )}`; + } + + switch (parameter.type) { + case WellknownValueTypes.BOOLEAN: + return html` + ) => onChange(ev.detail.value)} + > + `; + case WellknownValueTypes.INTEGER: + case WellknownValueTypes.LONG: + case WellknownValueTypes.NUMBER: + return html` + ) => + onChange(ev.detail.value === "" ? undefined : Number(ev.detail.value))} + > + `; + default: + return html` + ) => onChange(ev.detail.value)} + > + `; + } +} + +/** Whether every path of the parameter is the agent id of an agent link. */ +function isAgentLinkIdParameter(parameter: AttributeConfigurationGenericParameter): boolean { + const agentIdPath = `meta.${WellknownMetaItems.AGENTLINK}.id`; + return (parameter.paths || []).every((path) => path.split(".").slice(2).join(".") === agentIdPath); +} + +/** Whether a value can be submitted for a generic parameter of the given type. */ +function isGenericParameterValue(value: unknown, type?: string): boolean { + switch (type) { + case WellknownValueTypes.BOOLEAN: + return typeof value === "boolean"; + case WellknownValueTypes.INTEGER: + return Number.isInteger(value) && (value as number) >= -(2 ** 31) && (value as number) < 2 ** 31; + case WellknownValueTypes.LONG: + return Number.isSafeInteger(value); + case WellknownValueTypes.NUMBER: + return typeof value === "number" && Number.isFinite(value); + default: + return typeof value === "string" && value.trim() !== ""; + } +} + +/** + * The metadata values that can become a generic parameter: text, boolean and number values reached + * through object properties only, which are equal in every given attribute holding them. + */ +export function getGenericPathCandidates(attributes: Attribute[]): GenericPathCandidate[] { + const candidates = new Map(); + const differing = new Set(); + + attributes.forEach((attribute) => + collectScalars(attribute.meta, "meta", (path, value) => { + const candidate = candidates.get(path); + + if (!candidate) { + candidates.set(path, { path, value, attributes: [attribute] }); + return; + } + + candidate.attributes.push(attribute); + if (candidate.value !== value) { + differing.add(path); + } + }) + ); + + return [...candidates.values()] + .filter((candidate) => !differing.has(candidate.path)) + .sort(Util.sortByString((candidate) => candidate.path)); +} + +/** Names a generic parameter after its path, so meta.agentLink.id becomes agentLinkId. */ +function deriveGenericParameterName(path: string): string { + return path + .split(".") + .slice(1) + .map((segment, index) => (index === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1))) + .join(""); +} + +function collectScalars( + value: unknown, + path: string, + consumer: (path: string, value: string | number | boolean) => void +) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + Object.entries(value) + .filter(([key]) => key !== "" && !key.includes(".")) + .forEach(([key, child]) => collectScalars(child, `${path}.${key}`, consumer)); + } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + consumer(path, value); + } +} + +/** Reports what the import would change, before the final overwrite warning. */ +function showImportPreviewDialog( + host: Node, + asset: Asset, + fileName: string, + preview: AttributeConfigurationImportPreview, + onImported: (attributes: AssetAttributes) => void +) { + const patchedAttributes = (preview.patchedAttributes || {}) as AssetAttributes; + const importable = preview.importableAttributes || []; + + const dialog = showDialog( + host, + html` + getHeaderTemplate("attributeConfiguration.importHeading", () => dialog?.close()))} + ${dialogRenderer( + () => html` + ${dialogStyle} +
+ ${fileName} + ${getAssetTypeMismatchTemplate(preview)} +
+ + ${importable.map( + (attribute) => html` +
+ + ${attributeLabel(asset, patchedAttributes[attribute.name!], attribute.name)} + +
    + ${metaLabels(asset, patchedAttributes[attribute.name!]).map((label) => html`
  • ${label}
  • `)} +
+
+ ` + )} +
+ ${getSkippedTemplate(preview)} +
+ ` + )} + ${dialogFooterRenderer( + () => html` +
+ dialog?.close()}> + + + { + dialog?.close(); + showOverwriteWarningDialog( + host, + () => showImportPreviewDialog(host, asset, fileName, preview, onImported), + () => { + onImported(patchedAttributes); + showImportResultDialog(host, preview); + } + ); + }} + > + + +
+ ` + )} + >
+ ` + ); +} + +/** The last step before the draft is changed, which the user can still step back out of. */ +function showOverwriteWarningDialog(host: Node, onBack: () => void, onConfirm: () => void) { + const dialog: Ref = createRef(); + + showConfirmDialog( + host, + html` + onConfirm()}> + ${getConfirmDialogContent( + "", + html` + ${dialogStyle} +
+ { + dialog.value?.close(); + onBack(); + }} + > + + +

+ dialog.value?.close()}> + + +
+ `, + "attributeConfiguration.overwriteConfirm", + "attributeConfiguration.confirm", + "cancel" + )} +
+ ` + ); +} + +/** Reports the applied import, which the user still has to save or discard. */ +function showImportResultDialog(host: Node, preview: AttributeConfigurationImportPreview) { + const names = (preview.importableAttributes || []).map((attribute) => attribute.name).join(", "); + + showConfirmDialog( + host, + html` + + ${getConfirmDialogContent( + "", + "attributeConfiguration.importHeading", + html` +
${i18next.t("attributeConfiguration.imported", { attributes: names })}
+
+ `, + "close" + )} +
+ ` + ); +} + +/** Reports a document that cannot be imported, with whatever the server could tell about it. */ +function showImportFailureDialog(host: Node, error: unknown) { + const preview = getFailurePreview(error); + const forbidden = (error as { response?: { status?: number } })?.response?.status === 403; + const message = preview + ? i18next.t(`attributeConfiguration.failure.${preview.failure}`) + : i18next.t(forbidden ? "attributeConfiguration.forbidden" : "errorOccurred"); + + showErrorDialog( + host, + html`${dialogStyle} +
+ ${message} + ${preview ? getSkippedTemplate(preview) : ``} +
`, + "attributeConfiguration.importHeading" + ); +} + +function getHeaderTemplate(titleKey: string, onClose: () => void): TemplateResult { + return html` + ${dialogStyle} +
+

+ onClose()}> + + +
+ `; +} + +function getAssetTypeMismatchTemplate(preview: AttributeConfigurationImportPreview): TemplateResult | string { + return when( + preview.assetTypeMismatch, + () => html` +
+ + + ${i18next.t("attributeConfiguration.assetTypeMismatch", { + imported: Util.getAssetTypeLabel(preview.importedAssetType), + target: Util.getAssetTypeLabel(preview.targetAssetType), + })} + +
+ `, + () => `` + ); +} + +function getSkippedTemplate(preview: AttributeConfigurationImportPreview): TemplateResult | string { + const missing = preview.missingAttributes || []; + const mismatches = preview.typeMismatches || []; + + if (missing.length === 0 && mismatches.length === 0) { + return ``; + } + + return html` +
+ ${when( + missing.length > 0, + () => html` +
+ + + +
    + ${missing.map((attribute) => html`
  • ${attribute.name} (${attribute.type})
  • `)} +
+
+ ` + )} + ${when( + mismatches.length > 0, + () => html` +
+ + + +
    + ${mismatches.map( + (mismatch) => html`
  • ${mismatch.name} (${mismatch.importedType} → ${mismatch.targetType})
  • ` + )} +
+
+ ` + )} +
+ `; +} + +/** Expands or collapses the metadata of an attribute row. */ +function toggleRow(ev: Event) { + const button = ev.currentTarget as HTMLElement; + const row = button.closest(".ac-row"); + const icon = button.querySelector("or-icon") as OrIcon | null; + + if (!row) { + return; + } + + row.classList.toggle("ac-expanded"); + + if (icon) { + icon.icon = row.classList.contains("ac-expanded") ? "chevron-up" : "chevron-down"; + } +} + +async function downloadAttributeConfiguration(asset: Asset, attributeNames: string[], genericPaths: string[]) { + try { + const response = await manager.rest.api.AssetResource.exportAttributeConfiguration(asset.id!, { + attributeName: attributeNames, + genericPath: genericPaths, + }); + const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: "application/json" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `${asset.name}-attribute-config.json`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error("Failed to export attribute configuration", error); + showSnackbar(undefined, "errorOccurred"); + } +} + +/** The report the server sends along with a rejected import, if this is one. */ +function getFailurePreview(error: unknown): AttributeConfigurationImportPreview | undefined { + const data = (error as { response?: { data?: AttributeConfigurationImportPreview } })?.response?.data; + return data?.failure ? data : undefined; +} + +/** The attribute label with its value type, as the Figma dialogs show it, e.g. "Model (Text)". */ +function attributeLabel(asset: Asset, attribute?: Attribute, fallback?: string): string { + if (!attribute) { + return fallback || ""; + } + const descriptor = AssetModelUtil.getAttributeDescriptor(attribute.name!, asset.type!); + const label = Util.getAttributeLabel(attribute, descriptor, asset.type, false, fallback); + return `${label} (${Util.getValueDescriptorLabel(attribute.type)})`; +} + +function metaLabels(asset: Asset, attribute?: Attribute): string[] { + return Object.keys(attribute?.meta || {}) + .map((name) => Util.getMetaLabel(undefined, name, asset.type, false)) + .sort(); +} diff --git a/ui/component/or-asset-viewer/src/index.ts b/ui/component/or-asset-viewer/src/index.ts index 6c73e026d8..d1ce5cf9e3 100644 --- a/ui/component/or-asset-viewer/src/index.ts +++ b/ui/component/or-asset-viewer/src/index.ts @@ -325,7 +325,12 @@ declare global { } } -export function getPanel(id: string, panelConfig: PanelConfig, content: TemplateResult | undefined) { +export function getPanel( + id: string, + panelConfig: PanelConfig, + content: TemplateResult | undefined, + actions?: TemplateResult +) { if (!content) { return; } @@ -339,6 +344,7 @@ export function getPanel(id: string, panelConfig: PanelConfig, content: Template
+ ${actions ? html`
${actions}
` : ``}
${content}
@@ -1472,7 +1478,7 @@ export class OrAssetViewer extends subscribe(manager)(translate(i18next)(OrEleme if (editMode) { content = html`
- +
`; diff --git a/ui/component/or-asset-viewer/src/or-edit-asset-panel.ts b/ui/component/or-asset-viewer/src/or-edit-asset-panel.ts index afddc0df50..f3f2cb3dc7 100644 --- a/ui/component/or-asset-viewer/src/or-edit-asset-panel.ts +++ b/ui/component/or-asset-viewer/src/or-edit-asset-panel.ts @@ -39,6 +39,11 @@ import type { OrIcon } from "@openremote/or-icon"; import { showDialog, OrMwcDialog, type DialogAction } from "@openremote/or-mwc-components/or-mwc-dialog"; import { type ListItem, ListType, type OrMwcList } from "@openremote/or-mwc-components/or-mwc-list"; import "./or-add-attribute-panel"; +import { + type AssetAttributes, + showExportAttributeConfigurationDialog, + showImportAttributeConfigurationDialog, +} from "./attribute-configuration"; import { getField, getPanel, getPropertyTemplate } from "./index"; import type { OrAddAttributePanelAttributeChangedEvent } from "./or-add-attribute-panel"; import { panelStyles } from "./style"; @@ -210,6 +215,10 @@ export class OrEditAssetPanel extends OrElement { @property({ attribute: false }) protected asset!: Asset; + /** Whether the draft holds unsaved changes, which blocks exporting the attribute configuration. */ + @property({ type: Boolean }) + public modified = false; + protected attributeTemplatesAndValidators: TemplateAndValidator[] = []; protected changedAttributes: string[] = []; @@ -379,7 +388,14 @@ export class OrEditAssetPanel extends OrElement { return html`
${getPanel("0", { type: "info", title: "properties" }, html`${properties}`) || ``} - ${getPanel("1", { type: "info", title: "attribute_plural" }, html`${attributes}`) || ``} + ${ + getPanel( + "1", + { type: "info", title: "attribute_plural" }, + html`${attributes}`, + this._getConfigurationActionsTemplate() + ) || `` + }
`; } @@ -478,6 +494,38 @@ export class OrEditAssetPanel extends OrElement { }; } + protected _getConfigurationActionsTemplate(): TemplateResult { + return html` + this._importConfiguration()}> + + + + showExportAttributeConfigurationDialog(this.shadowRoot!, this.asset)} + > + + + + `; + } + + protected _importConfiguration() { + showImportAttributeConfigurationDialog(this.shadowRoot!, this.asset, (attributes) => + this._onConfigurationImported(attributes) + ); + } + + protected async _onConfigurationImported(attributes: AssetAttributes) { + this.asset.attributes = attributes; + + // Render the imported metadata before validating it + this.requestUpdate(); + await this.updateComplete; + this._onModified(); + } + protected _onModified() { this.dispatchEvent(new OrEditAssetModifiedEvent(this.validate())); } diff --git a/ui/component/or-asset-viewer/src/style.ts b/ui/component/or-asset-viewer/src/style.ts index 34207eed91..ab31723cd4 100644 --- a/ui/component/or-asset-viewer/src/style.ts +++ b/ui/component/or-asset-viewer/src/style.ts @@ -61,6 +61,8 @@ export const panelStyles = css` } .panel-title { + display: flex; + align-items: center; font-size: 18px; font-weight: bold; color: var(--internal-or-asset-viewer-title-text-color); @@ -68,6 +70,14 @@ export const panelStyles = css` flex: 0 0 auto; } + .panel-title-actions { + display: flex; + gap: 8px; + margin-left: auto; + font-size: initial; + font-weight: initial; + } + .field { margin: 10px 0; width: 100%; diff --git a/ui/component/or-attribute-input/src/index.ts b/ui/component/or-attribute-input/src/index.ts index e52012a060..de33765caa 100644 --- a/ui/component/or-attribute-input/src/index.ts +++ b/ui/component/or-attribute-input/src/index.ts @@ -66,6 +66,8 @@ import { agentIdRendererRegistryEntry } from "./renderers/agent-link"; import { schedulerRendererRegistryEntry } from "./renderers/scheduler"; import { replayDataRendererRegistryEntry } from "./renderers/replay-data"; +export { getAgentOptions, loadAgents } from "./renderers/agent-link"; + export class OrAttributeInputChangedEvent extends CustomEvent { public static readonly NAME = "or-attribute-input-changed"; diff --git a/ui/component/or-attribute-input/src/renderers/agent-link.ts b/ui/component/or-attribute-input/src/renderers/agent-link.ts index d89840f97e..17c0fbcfa3 100644 --- a/ui/component/or-attribute-input/src/renderers/agent-link.ts +++ b/ui/component/or-attribute-input/src/renderers/agent-link.ts @@ -46,7 +46,7 @@ let loadingPromise: Promise | undefined; let subscribed = false; const timeout = 2000; -function loadAgents(): PromiseLike { +export function loadAgents(): PromiseLike { if (agents) { return Promise.resolve(agents); } @@ -93,6 +93,11 @@ function loadAgents(): PromiseLike { return loadingPromise; } +/** The agents to pick from for an agent link, as value and label pairs. */ +export function getAgentOptions(agents: Agent[]): [string, string][] { + return agents.map((agent) => [agent.id!, agent.name + " (" + agent.id + ")"]); +} + const agentIdTester: RankedTester = rankWith(6, and(uiTypeIs("Control"), formatIs("or-agent-id"))); const agentIdRenderer = (state: JsonFormsStateContext, props: ControlProps) => { props = { @@ -118,7 +123,7 @@ const agentIdRenderer = (state: JsonFormsStateContext, props: ControlProps) => { }; const loadedTemplatePromise = loadAgents().then((agents) => { - const options: [string, string][] = agents.map((agent) => [agent.id!, agent.name + " (" + agent.id + ")"]); + const options = getAgentOptions(agents); return html`