From 95ddef24f37ff0844da151deb269b7cf3931548b Mon Sep 17 00:00:00 2001 From: Laurent Garnier Date: Wed, 30 Apr 2025 21:20:43 +0200 Subject: [PATCH 1/3] [REST] New API for conversion between file formats Related to #4585 This PR only supports management of things. It supports DSL and YAML file formats. A first new API (POST /file-format/create) allows to create a file format (DSL or YAML) from a JSON object. A second new API (POST /file-format/parse) allows to parse a file format (DSL or YAML) to a JSON object. These 2 APIs should help Main UI displaying DSL and YAML file formats for things. Signed-off-by: Laurent Garnier --- .../rest/core/fileformat/FileFormatDTO.java | 28 ++ .../fileformat/extendedFileFormatDTO.java | 26 ++ .../fileformat/FileFormatResource.java | 315 ++++++++++++++++- .../core/model/core/ModelRepository.java | 17 + .../core/internal/ModelRepositoryImpl.java | 100 ++++-- .../thing/internal/GenericThingProvider.xtend | 61 ++-- .../fileconverter/DslThingFileConverter.java | 45 ++- .../core/model/yaml/YamlModelRepository.java | 24 ++ .../internal/YamlModelRepositoryImpl.java | 319 ++++++++++-------- .../internal/things/YamlThingProvider.java | 28 +- .../fileconverter/YamlThingFileConverter.java | 38 ++- .../fileconverter/ThingFileGenerator.java | 4 +- .../thing/fileconverter/ThingFileParser.java | 46 +++ 13 files changed, 833 insertions(+), 218 deletions(-) create mode 100644 bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java create mode 100644 bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/extendedFileFormatDTO.java create mode 100644 bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileParser.java diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java new file mode 100644 index 00000000000..27d9076ea96 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.core.fileformat; + +import java.util.List; + +import org.openhab.core.thing.dto.ThingDTO; + +/** + * This is a data transfer object to serialize the different components that can be contained + * in a file format (items, things, ...). + * + * @author Laurent Garnier - Initial contribution + */ +public class FileFormatDTO { + + public List things; +} diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/extendedFileFormatDTO.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/extendedFileFormatDTO.java new file mode 100644 index 00000000000..61093a1aa64 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/extendedFileFormatDTO.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.core.fileformat; + +import java.util.List; + +/** + * This is a data transfer object to serialize the different components that can be contained + * in a file format (items, things, ...) including an optional list of warnings. + * + * @author Laurent Garnier - Initial contribution + */ +public class extendedFileFormatDTO extends FileFormatDTO { + + public List warnings; +} diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java index 4db9eb1384b..fd001ddbb9d 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java @@ -16,6 +16,7 @@ import java.io.ByteArrayOutputStream; import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -27,6 +28,7 @@ import java.util.stream.Stream; import javax.annotation.security.RolesAllowed; +import javax.ws.rs.BadRequestException; import javax.ws.rs.Consumes; import javax.ws.rs.DefaultValue; import javax.ws.rs.POST; @@ -50,6 +52,8 @@ import org.openhab.core.config.discovery.inbox.Inbox; import org.openhab.core.io.rest.RESTConstants; import org.openhab.core.io.rest.RESTResource; +import org.openhab.core.io.rest.core.fileformat.FileFormatDTO; +import org.openhab.core.io.rest.core.fileformat.extendedFileFormatDTO; import org.openhab.core.items.GroupItem; import org.openhab.core.items.Item; import org.openhab.core.items.ItemRegistry; @@ -58,16 +62,26 @@ import org.openhab.core.items.MetadataRegistry; import org.openhab.core.items.fileconverter.ItemFileGenerator; import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingRegistry; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.ThingFactory; +import org.openhab.core.thing.dto.ChannelDTO; +import org.openhab.core.thing.dto.ThingDTO; +import org.openhab.core.thing.dto.ThingDTOMapper; import org.openhab.core.thing.fileconverter.ThingFileGenerator; +import org.openhab.core.thing.fileconverter.ThingFileParser; import org.openhab.core.thing.link.ItemChannelLink; import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.BridgeType; +import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.thing.type.ChannelTypeRegistry; +import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.thing.type.ThingType; import org.openhab.core.thing.type.ThingTypeRegistry; +import org.openhab.core.thing.util.ThingHelper; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Deactivate; @@ -86,6 +100,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -98,6 +113,7 @@ * * @author Laurent Garnier - Initial contribution * @author Laurent Garnier - Add YAML output for things + * @author Laurent Garnier - Add new API for conversion between file formats */ @Component @JaxrsResource @@ -183,9 +199,11 @@ public class FileFormatResource implements RESTResource { private final ThingRegistry thingRegistry; private final Inbox inbox; private final ThingTypeRegistry thingTypeRegistry; + private final ChannelTypeRegistry channelTypeRegistry; private final ConfigDescriptionRegistry configDescRegistry; private final Map itemFileGenerators = new ConcurrentHashMap<>(); private final Map thingFileGenerators = new ConcurrentHashMap<>(); + private final Map thingFileParsers = new ConcurrentHashMap<>(); @Activate public FileFormatResource(// @@ -195,6 +213,7 @@ public FileFormatResource(// final @Reference ThingRegistry thingRegistry, // final @Reference Inbox inbox, // final @Reference ThingTypeRegistry thingTypeRegistry, // + final @Reference ChannelTypeRegistry channelTypeRegistry, // final @Reference ConfigDescriptionRegistry configDescRegistry) { this.itemRegistry = itemRegistry; this.metadataRegistry = metadataRegistry; @@ -202,6 +221,7 @@ public FileFormatResource(// this.thingRegistry = thingRegistry; this.inbox = inbox; this.thingTypeRegistry = thingTypeRegistry; + this.channelTypeRegistry = channelTypeRegistry; this.configDescRegistry = configDescRegistry; } @@ -227,6 +247,15 @@ protected void removeThingFileGenerator(ThingFileGenerator thingFileGenerator) { thingFileGenerators.remove(thingFileGenerator.getFileFormatGenerator()); } + @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.MULTIPLE) + protected void addThingFileParser(ThingFileParser thingFileParser) { + thingFileParsers.put(thingFileParser.getFileFormatParser(), thingFileParser); + } + + protected void removeThingFileParser(ThingFileParser thingFileParser) { + thingFileParsers.remove(thingFileParser.getFileFormatParser()); + } + @POST @RolesAllowed({ Role.ADMIN }) @Path("/items") @@ -301,10 +330,111 @@ public Response createFileFormatForThings(final @Context HttpHeaders httpHeaders } } ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - generator.generateFileFormat(outputStream, sortThings(things), hideDefaultParameters); + generator.generateFileFormat(outputStream, sortThings(things), true, hideDefaultParameters); return Response.ok(new String(outputStream.toByteArray())).build(); } + @POST + @RolesAllowed({ Role.ADMIN }) + @Path("/create") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ "text/vnd.openhab.dsl.thing", "application/yaml" }) + @Operation(operationId = "create", summary = "Create file format.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK", content = { + @Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = DSL_THINGS_EXAMPLE)), + @Content(mediaType = "application/yaml", schema = @Schema(example = YAML_THINGS_EXAMPLE)) }), + @ApiResponse(responseCode = "400", description = "Invalid JSON data."), + @ApiResponse(responseCode = "415", description = "Unsupported media type.") }) + public Response create(final @Context HttpHeaders httpHeaders, + @DefaultValue("false") @QueryParam("hideDefaultParameters") @Parameter(description = "hide the configuration parameters having the default value") boolean hideDefaultParameters, + @DefaultValue("false") @QueryParam("hideDefaultChannels") @Parameter(description = "hide the non extensible channels having a default configuration") boolean hideDefaultChannels, + @RequestBody(description = "JSON data", required = true, content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = FileFormatDTO.class))) FileFormatDTO data) { + String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); + logger.debug("create: mediaType = {}", acceptHeader); + + List things = new ArrayList<>(); + List errors = new ArrayList<>(); + if (!convertFromFileFormatDTO(data, things, errors)) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } + + String result = ""; + ByteArrayOutputStream outputStream; + ThingFileGenerator thingGenerator = getThingFileGenerator(acceptHeader); + switch (acceptHeader) { + case "text/vnd.openhab.dsl.thing": + if (thingGenerator == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } else if (things.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST).entity("No thing loaded from input").build(); + } + outputStream = new ByteArrayOutputStream(); + thingGenerator.generateFileFormat(outputStream, things, hideDefaultChannels, hideDefaultParameters); + result = new String(outputStream.toByteArray()); + break; + case "application/yaml": + if (thingGenerator != null) { + outputStream = new ByteArrayOutputStream(); + thingGenerator.generateFileFormat(outputStream, things, hideDefaultChannels, hideDefaultParameters); + result = new String(outputStream.toByteArray()); + } + break; + default: + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } + return Response.ok(result).build(); + } + + @POST + @RolesAllowed({ Role.ADMIN }) + @Path("/parse") + @Consumes({ "text/vnd.openhab.dsl.thing", "application/yaml" }) + @Produces(MediaType.APPLICATION_JSON) + @Operation(operationId = "parse", summary = "Parse file format.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = extendedFileFormatDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid input data."), + @ApiResponse(responseCode = "415", description = "Unsupported content type.") }) + public Response parse(final @Context HttpHeaders httpHeaders, + @RequestBody(description = "file format syntax", required = true, content = { + @Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = DSL_THINGS_EXAMPLE)), + @Content(mediaType = "application/yaml", schema = @Schema(example = YAML_THINGS_EXAMPLE)) }) String input) { + String contentTypetHeader = httpHeaders.getHeaderString(HttpHeaders.CONTENT_TYPE); + String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); + logger.debug("transform: contentType = {}, mediaType = {}", contentTypetHeader, acceptHeader); + + // First parse the input + List things = new ArrayList<>(); + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + ThingFileParser thingParser = getThingFileParser(contentTypetHeader); + switch (contentTypetHeader) { + case "text/vnd.openhab.dsl.thing": + if (thingParser == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported content type '" + contentTypetHeader + "'!").build(); + } else if (!thingParser.parseFileFormat(input, things, errors, warnings)) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } else if (things.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST).entity("No thing loaded from input").build(); + } + break; + case "application/yaml": + if (thingParser != null && !thingParser.parseFileFormat(input, things, errors, warnings)) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } + break; + default: + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported content type '" + contentTypetHeader + "'!").build(); + } + + return Response.ok(convertToFileFormatDTO(things, warnings)).build(); + } + /* * Get all the metadata for a list of items including channel links mapped to metadata in the namespace "channel" */ @@ -463,6 +593,14 @@ private Thing simulateThing(DiscoveryResult result, ThingType thingType) { }; } + private @Nullable ThingFileParser getThingFileParser(String contentType) { + return switch (contentType) { + case "text/vnd.openhab.dsl.thing" -> thingFileParsers.get("DSL"); + case "application/yaml" -> thingFileParsers.get("YAML"); + default -> null; + }; + } + private List getThingsOrDiscoveryResult(List thingUIDs) { return thingUIDs.stream().distinct().map(uid -> { ThingUID thingUID = new ThingUID(uid); @@ -483,4 +621,179 @@ private List getThingsOrDiscoveryResult(List thingUIDs) { return simulateThing(discoveryResult, thingType); }).toList(); } + + private boolean convertFromFileFormatDTO(FileFormatDTO data, List things, List errors) { + boolean ok = true; + if (data.things != null) { + for (ThingDTO thingBean : data.things) { + ThingUID thingUID = thingBean.UID == null ? null : new ThingUID(thingBean.UID); + ThingTypeUID thingTypeUID = new ThingTypeUID(thingBean.thingTypeUID); + + ThingUID bridgeUID = null; + + if (thingBean.bridgeUID != null) { + bridgeUID = new ThingUID(thingBean.bridgeUID); + if (thingUID != null && (!thingUID.getBindingId().equals(bridgeUID.getBindingId()) + || !thingUID.getBridgeIds().contains(bridgeUID.getId()))) { + errors.add("Thing UID '" + thingUID + "' does not match bridge UID '" + bridgeUID + "'"); + ok = false; + continue; + } + } + + // turn the ThingDTO's configuration into a Configuration + Configuration configuration = new Configuration( + normalizeConfiguration(thingBean.configuration, thingTypeUID, thingUID)); + if (thingUID != null) { + normalizeChannels(thingBean, thingUID); + } + + Thing thing = thingRegistry.createThingOfType(thingTypeUID, thingUID, bridgeUID, thingBean.label, + configuration); + + if (thing != null) { + if (thingBean.properties != null) { + for (Map.Entry entry : thingBean.properties.entrySet()) { + thing.setProperty(entry.getKey(), entry.getValue()); + } + } + if (thingBean.location != null) { + thing.setLocation(thingBean.location); + } + if (thingBean.channels != null) { + // The provided channels replace the channels provided by the thing type. + ThingDTO thingChannels = new ThingDTO(); + thingChannels.channels = thingBean.channels; + thing = ThingHelper.merge(thing, thingChannels); + } + } else if (thingUID != null) { + // if there wasn't any ThingFactory capable of creating the thing, + // we create the Thing exactly the way we received it, i.e. we + // cannot take its thing type into account for automatically + // populating channels and properties. + thing = ThingDTOMapper.map(thingBean, + thingTypeRegistry.getThingType(thingTypeUID) instanceof BridgeType); + } else { + errors.add("A thing UID must be provided, since no binding can create the thing!"); + ok = false; + continue; + } + + things.add(thing); + } + } + return ok; + } + + private extendedFileFormatDTO convertToFileFormatDTO(List things, List warnings) { + extendedFileFormatDTO dto = new extendedFileFormatDTO(); + dto.warnings = warnings.isEmpty() ? null : warnings; + if (!things.isEmpty()) { + dto.things = new ArrayList<>(); + things.forEach(thing -> { + dto.things.add(ThingDTOMapper.map(thing)); + }); + } + return dto; + } + + private @Nullable Map normalizeConfiguration( + @Nullable Map properties, ThingTypeUID thingTypeUID, + @Nullable ThingUID thingUID) { + if (properties == null || properties.isEmpty()) { + return properties; + } + + ThingType thingType = thingTypeRegistry.getThingType(thingTypeUID); + if (thingType == null) { + return properties; + } + + List configDescriptions = new ArrayList<>(2); + + URI descURI = thingType.getConfigDescriptionURI(); + if (descURI != null) { + ConfigDescription typeConfigDesc = configDescRegistry.getConfigDescription(descURI); + if (typeConfigDesc != null) { + configDescriptions.add(typeConfigDesc); + } + } + + if (thingUID != null) { + ConfigDescription thingConfigDesc = configDescRegistry + .getConfigDescription(getConfigDescriptionURI(thingUID)); + if (thingConfigDesc != null) { + configDescriptions.add(thingConfigDesc); + } + } + + if (configDescriptions.isEmpty()) { + return properties; + } + + return ConfigUtil.normalizeTypes(properties, configDescriptions); + } + + private @Nullable Map normalizeConfiguration(Map properties, + ChannelTypeUID channelTypeUID, ChannelUID channelUID) { + if (properties == null || properties.isEmpty()) { + return properties; + } + + ChannelType channelType = channelTypeRegistry.getChannelType(channelTypeUID); + if (channelType == null) { + return properties; + } + + List configDescriptions = new ArrayList<>(2); + URI descURI = channelType.getConfigDescriptionURI(); + if (descURI != null) { + ConfigDescription typeConfigDesc = configDescRegistry.getConfigDescription(descURI); + if (typeConfigDesc != null) { + configDescriptions.add(typeConfigDesc); + } + } + if (getConfigDescriptionURI(channelUID) != null) { + ConfigDescription channelConfigDesc = configDescRegistry + .getConfigDescription(getConfigDescriptionURI(channelUID)); + if (channelConfigDesc != null) { + configDescriptions.add(channelConfigDesc); + } + } + + if (configDescriptions.isEmpty()) { + return properties; + } + + return ConfigUtil.normalizeTypes(properties, configDescriptions); + } + + private void normalizeChannels(ThingDTO thingBean, ThingUID thingUID) { + if (thingBean.channels != null) { + for (ChannelDTO channelBean : thingBean.channels) { + if (channelBean.channelTypeUID != null) { + channelBean.configuration = normalizeConfiguration(channelBean.configuration, + new ChannelTypeUID(channelBean.channelTypeUID), new ChannelUID(thingUID, channelBean.id)); + } + } + } + } + + private URI getConfigDescriptionURI(ThingUID thingUID) { + String uriString = "thing:" + thingUID; + try { + return new URI(uriString); + } catch (URISyntaxException e) { + throw new BadRequestException("Invalid URI syntax: " + uriString); + } + } + + private URI getConfigDescriptionURI(ChannelUID channelUID) { + String uriString = "channel:" + channelUID; + try { + return new URI(uriString); + } catch (URISyntaxException e) { + throw new BadRequestException("Invalid URI syntax: " + uriString); + } + } } diff --git a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/ModelRepository.java b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/ModelRepository.java index 11a76a3eb30..1c379c5a026 100644 --- a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/ModelRepository.java +++ b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/ModelRepository.java @@ -14,6 +14,7 @@ import java.io.InputStream; import java.io.OutputStream; +import java.util.List; import java.util.Set; import org.eclipse.emf.ecore.EObject; @@ -28,6 +29,7 @@ * * @author Kai Kreuzer - Initial contribution * @author Laurent Garnier - Added method generateSyntaxFromModel + * @author Laurent Garnier - Added method createTemporaryModel */ @NonNullByDefault public interface ModelRepository { @@ -95,6 +97,21 @@ public interface ModelRepository { */ void removeModelRepositoryChangeListener(ModelRepositoryChangeListener listener); + /** + * Creates a temporary model in the repository + * + * A temporary model is not attached to a file on disk. + * A temporary model will be loaded without impacting any object registry. + * + * @param modelType the model type + * @param inputStream an input stream with the model's content + * @param errors the list to be used to fill the errors + * @param warnings the list to be used to fill the warnings + * @return the created model name if it was successfully processed, null otherwise + */ + @Nullable + String createTemporaryModel(String modelType, InputStream inputStream, List errors, List warnings); + /** * Generate the syntax from a provided model content. * diff --git a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java index fce56c34aec..b2953aec3d3 100644 --- a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java +++ b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java @@ -20,7 +20,6 @@ import java.text.MessageFormat; import java.util.ArrayList; import java.util.HashSet; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; @@ -52,11 +51,14 @@ * @author Oliver Libutzki - Added reloadAllModelsOfType method * @author Simon Kaufmann - added validation of models before loading them * @author Laurent Garnier - Added method generateSyntaxFromModel + * @author Laurent Garnier - Added method createTemporaryModel */ @Component(immediate = true) @NonNullByDefault public class ModelRepositoryImpl implements ModelRepository { + private static final String PREFIX_TMP_MODEL = "tmp_"; + private final Logger logger = LoggerFactory.getLogger(ModelRepositoryImpl.class); private final ResourceSet resourceSet; private final Map resourceOptions = Map.of(XtextResource.OPTION_ENCODING, @@ -87,12 +89,12 @@ public ModelRepositoryImpl(final @Reference SafeEMF safeEmf) { if (!resource.getContents().isEmpty()) { return resource.getContents().getFirst(); } else { - logger.warn("Configuration model '{}' is either empty or cannot be parsed correctly!", name); + logger.warn("DSL model '{}' is either empty or cannot be parsed correctly!", name); resourceSet.getResources().remove(resource); return null; } } else { - logger.trace("Configuration model '{}' can not be found", name); + logger.trace("DSL model '{}' can not be found", name); return null; } } @@ -100,19 +102,39 @@ public ModelRepositoryImpl(final @Reference SafeEMF safeEmf) { @Override public boolean addOrRefreshModel(String name, final InputStream originalInputStream) { - logger.info("Loading model '{}'", name); + return addOrRefreshModel(name, originalInputStream, null, null); + } + + public boolean addOrRefreshModel(String name, final InputStream originalInputStream, @Nullable List errors, + @Nullable List warnings) { + logger.info("Loading DSL model '{}'", name); Resource resource = null; byte[] bytes; try (InputStream inputStream = originalInputStream) { bytes = inputStream.readAllBytes(); - String validationResult = validateModel(name, new ByteArrayInputStream(bytes)); - if (validationResult != null) { - logger.warn("Configuration model '{}' has errors, therefore ignoring it: {}", name, validationResult); + List newErrors = new ArrayList<>(); + List newWarnings = new ArrayList<>(); + boolean valid = validateModel(name, new ByteArrayInputStream(bytes), newErrors, newWarnings); + if (errors != null) { + errors.addAll(newErrors); + } + if (warnings != null) { + warnings.addAll(newWarnings); + } + if (!valid) { + logger.warn("DSL model '{}' has errors, therefore ignoring it: {}", name, String.join("\n", newErrors)); removeModel(name); return false; } + if (!newWarnings.isEmpty()) { + logger.info("Validation issues found in DSL model '{}', using it anyway:\n{}", name, + String.join("\n", newWarnings)); + } } catch (IOException e) { - logger.warn("Configuration model '{}' cannot be parsed correctly!", name, e); + if (errors != null) { + errors.add("Model cannot be parsed correctly: %s".formatted(e.getMessage())); + } + logger.warn("DSL model '{}' cannot be parsed correctly!", name, e); return false; } try (InputStream inputStream = new ByteArrayInputStream(bytes)) { @@ -144,7 +166,10 @@ public boolean addOrRefreshModel(String name, final InputStream originalInputStr } } } catch (IOException e) { - logger.warn("Configuration model '{}' cannot be parsed correctly!", name, e); + if (errors != null) { + errors.add("Model cannot be parsed correctly: %s".formatted(e.getMessage())); + } + logger.warn("DSL model '{}' cannot be parsed correctly!", name, e); if (resource != null) { resourceSet.getResources().remove(resource); } @@ -176,7 +201,7 @@ public Iterable getAllModelNamesOfType(final String modelType) { return resourceListCopy.stream() .filter(input -> input.getURI().lastSegment().contains(".") && input.isLoaded() && modelType.equalsIgnoreCase(input.getURI().fileExtension()) - && !input.getURI().lastSegment().startsWith("tmp_")) + && !isTemporaryModel(input.getURI().lastSegment())) .map(from -> from.getURI().path()).toList(); } } @@ -189,7 +214,7 @@ public void reloadAllModelsOfType(final String modelType) { for (Resource resource : resourceListCopy) { if (resource.getURI().lastSegment().contains(".") && resource.isLoaded() && modelType.equalsIgnoreCase(resource.getURI().fileExtension()) - && !resource.getURI().lastSegment().startsWith("tmp_")) { + && !isTemporaryModel(resource.getURI().lastSegment())) { XtextResource xtextResource = (XtextResource) resource; // It's not sufficient to discard the derived state. // The quick & dirts solution is to reparse the whole resource. @@ -211,7 +236,7 @@ public Set removeAllModelsOfType(final String modelType) { for (Resource resource : resourceListCopy) { if (resource.getURI().lastSegment().contains(".") && resource.isLoaded() && modelType.equalsIgnoreCase(resource.getURI().fileExtension()) - && !resource.getURI().lastSegment().startsWith("tmp_")) { + && !isTemporaryModel(resource.getURI().lastSegment())) { logger.debug("Removing resource '{}'", resource.getURI().lastSegment()); ret.add(resource.getURI().lastSegment()); resourceSet.getResources().remove(resource); @@ -232,16 +257,27 @@ public void removeModelRepositoryChangeListener(ModelRepositoryChangeListener li listeners.remove(listener); } + @Override + public @Nullable String createTemporaryModel(String modelType, InputStream inputStream, List errors, + List warnings) { + String name = "%smodel_%d.%s".formatted(PREFIX_TMP_MODEL, ++counter, modelType); + return addOrRefreshModel(name, inputStream, errors, warnings) ? name : null; + } + + private boolean isTemporaryModel(String modelName) { + return modelName.startsWith(PREFIX_TMP_MODEL); + } + @Override public void generateSyntaxFromModel(OutputStream out, String modelType, EObject modelContent) { synchronized (resourceSet) { - String name = "tmp_generated_syntax_%d.%s".formatted(++counter, modelType); + String name = "%sgenerated_syntax_%d.%s".formatted(PREFIX_TMP_MODEL, ++counter, modelType); Resource resource = resourceSet.createResource(URI.createURI(name)); try { resource.getContents().add(modelContent); resource.save(out, Map.of(XtextResource.OPTION_ENCODING, StandardCharsets.UTF_8.name())); } catch (IOException e) { - logger.warn("Exception when saving the model {}", resource.getURI().lastSegment()); + logger.warn("Exception when saving DSL model {}", resource.getURI().lastSegment()); } finally { resourceSet.getResources().remove(resource); } @@ -268,28 +304,28 @@ public void generateSyntaxFromModel(OutputStream out, String modelType, EObject * Validation will be done on a separate resource, in order to keep the original one intact in case its content * needs to be removed because of syntactical errors. * - * @param name - * @param inputStream - * @return error messages as a String if any syntactical error were found, null otherwise + * @param name the model name + * @param inputStream an input stream with the model's content + * @param errors the list to be used to fill the errors + * @param warnings the list to be used to fill the warnings + * @return false if any syntactical error were found, false otherwise * @throws IOException if there was an error with the given {@link InputStream}, loading the resource from there */ - private @Nullable String validateModel(String name, InputStream inputStream) throws IOException { + private boolean validateModel(String name, InputStream inputStream, List errors, List warnings) + throws IOException { // use another resource for validation in order to keep the original one for emergency-removal in case of errors - Resource resource = resourceSet.createResource(URI.createURI("tmp_" + name)); + Resource resource = resourceSet.createResource(URI.createURI(PREFIX_TMP_MODEL + name)); try { resource.load(inputStream, resourceOptions); - StringBuilder criticalErrors = new StringBuilder(); - List warnings = new LinkedList<>(); if (!resource.getContents().isEmpty()) { // Check for syntactical errors for (Diagnostic diagnostic : resource.getErrors()) { - criticalErrors - .append(MessageFormat.format("[{0},{1}]: {2}\n", Integer.toString(diagnostic.getLine()), - Integer.toString(diagnostic.getColumn()), diagnostic.getMessage())); + errors.add(MessageFormat.format("[{0},{1}]: {2}", Integer.toString(diagnostic.getLine()), + Integer.toString(diagnostic.getColumn()), diagnostic.getMessage())); } - if (!criticalErrors.isEmpty()) { - return criticalErrors.toString(); + if (!resource.getErrors().isEmpty()) { + return false; } // Check for validation errors, but log them only @@ -299,10 +335,6 @@ public void generateSyntaxFromModel(OutputStream out, String modelType, EObject for (org.eclipse.emf.common.util.Diagnostic d : diagnostic.getChildren()) { warnings.add(d.getMessage()); } - if (!warnings.isEmpty()) { - logger.info("Validation issues found in configuration model '{}', using it anyway:\n{}", name, - String.join("\n", warnings)); - } } catch (NullPointerException e) { // see https://github.com/eclipse/smarthome/issues/3335 logger.debug("Validation of '{}' skipped due to internal errors.", name); @@ -311,12 +343,14 @@ public void generateSyntaxFromModel(OutputStream out, String modelType, EObject } finally { resourceSet.getResources().remove(resource); } - return null; + return true; } private void notifyListeners(String name, EventType type) { - for (ModelRepositoryChangeListener listener : listeners) { - listener.modelChanged(name, type); + if (!isTemporaryModel(name)) { + for (ModelRepositoryChangeListener listener : listeners) { + listener.modelChanged(name, type); + } } } } diff --git a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/GenericThingProvider.xtend b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/GenericThingProvider.xtend index c8cf4f43a49..89124d9d331 100644 --- a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/GenericThingProvider.xtend +++ b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/GenericThingProvider.xtend @@ -71,8 +71,9 @@ import org.slf4j.LoggerFactory * factory cannot load a thing yet (bug 470368), * added delay until ThingTypes are fully loaded * @author Markus Rathgeb - Add locale provider support + * @author Laurent Garnier - Add method getThingsFromModel */ -@Component(immediate=true, service=ThingProvider) +@Component(immediate=true, service=#[ ThingProvider, GenericThingProvider ]) class GenericThingProvider extends AbstractProviderLazyNullness implements ThingProvider, ModelRepositoryChangeListener, ReadyService.ReadyTracker { static final String XML_THING_TYPE = "openhab.xmlThingTypes"; @@ -122,25 +123,7 @@ class GenericThingProvider extends AbstractProviderLazyNullness implement return } flattenModelThings(model.things).map [ - // Get the ThingHandlerFactories - val ThingUID thingUID = constructThingUID - if (thingUID === null) { - // ignore the Thing because its definition is broken - return null - } - val thingTypeUID = constructThingTypeUID(thingUID) - if (thingTypeUID === null) { - // ignore the Thing because its definition is broken - return null - } - val factory = thingHandlerFactories.findFirst [ - supportsThingType(thingTypeUID) - ] - if (factory === null && modelLoaded) { - logger.info("No ThingHandlerFactory found for thing {} (thing-type is {}). Deferring initialization.", - thingUID, thingTypeUID) - } - return factory + return getThingHandlerFactory ]?.filter [ // Drop it if there is no ThingHandlerFactory yet which can handle it it !== null @@ -151,6 +134,27 @@ class GenericThingProvider extends AbstractProviderLazyNullness implement } } + def private ThingHandlerFactory getThingHandlerFactory(ModelThing modelThing) { + val ThingUID thingUID = constructThingUID(modelThing) + if (thingUID === null) { + // ignore the Thing because its definition is broken + return null + } + val thingTypeUID = constructThingTypeUID(modelThing, thingUID) + if (thingTypeUID === null) { + // ignore the Thing because its definition is broken + return null + } + val factory = thingHandlerFactories.findFirst [ + supportsThingType(thingTypeUID) + ] + if (factory == null && modelLoaded) { + logger.info("No ThingHandlerFactory found for thing {} (thing-type is {}). Deferring initialization.", + thingUID, thingTypeUID) + } + return factory + } + def private ThingUID constructThingUID(ModelThing modelThing) { if (modelThing.id !== null) { return new ThingUID(modelThing.id) @@ -487,6 +491,23 @@ class GenericThingProvider extends AbstractProviderLazyNullness implement } } + def public List getThingsFromModel(String modelName) { + val things = newArrayList() + if (modelRepository !== null && modelName.endsWith("things")) { + logger.debug("Read things from model '{}'", modelName); + val model = modelRepository.getModel(modelName) as ThingModel + if (model !== null) { + flattenModelThings(model.things).forEach [ + val factory = getThingHandlerFactory + if (factory !== null && loadedXmlThingTypes.contains(factory.bundleName)) { + createThing(things, factory) + } + ] + } + } + return things + } + def private Set getAllThingUIDs(ThingModel model) { return getAllThingUIDs(model.things, null) } diff --git a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingFileConverter.java b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingFileConverter.java index d1825447e31..1a237661909 100644 --- a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingFileConverter.java +++ b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingFileConverter.java @@ -12,6 +12,7 @@ */ package org.openhab.core.model.thing.internal.fileconverter; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -24,6 +25,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.config.core.ConfigDescriptionRegistry; import org.openhab.core.model.core.ModelRepository; +import org.openhab.core.model.thing.internal.GenericThingProvider; import org.openhab.core.model.thing.thing.ModelBridge; import org.openhab.core.model.thing.thing.ModelChannel; import org.openhab.core.model.thing.thing.ModelProperty; @@ -36,6 +38,7 @@ import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.fileconverter.AbstractThingFileGenerator; import org.openhab.core.thing.fileconverter.ThingFileGenerator; +import org.openhab.core.thing.fileconverter.ThingFileParser; import org.openhab.core.thing.type.ChannelKind; import org.openhab.core.thing.type.ChannelTypeRegistry; import org.openhab.core.thing.type.ChannelTypeUID; @@ -53,20 +56,22 @@ * @author Laurent Garnier - Initial contribution */ @NonNullByDefault -@Component(immediate = true, service = ThingFileGenerator.class) -public class DslThingFileConverter extends AbstractThingFileGenerator { +@Component(immediate = true, service = { ThingFileGenerator.class, ThingFileParser.class }) +public class DslThingFileConverter extends AbstractThingFileGenerator implements ThingFileParser { private final Logger logger = LoggerFactory.getLogger(DslThingFileConverter.class); private final ModelRepository modelRepository; + private final GenericThingProvider thingProvider; @Activate public DslThingFileConverter(final @Reference ModelRepository modelRepository, - final @Reference ThingTypeRegistry thingTypeRegistry, + final @Reference GenericThingProvider thingProvider, final @Reference ThingTypeRegistry thingTypeRegistry, final @Reference ChannelTypeRegistry channelTypeRegistry, final @Reference ConfigDescriptionRegistry configDescRegistry) { super(thingTypeRegistry, channelTypeRegistry, configDescRegistry); this.modelRepository = modelRepository; + this.thingProvider = thingProvider; } @Override @@ -75,15 +80,16 @@ public String getFileFormatGenerator() { } @Override - public synchronized void generateFileFormat(OutputStream out, List things, boolean hideDefaultParameters) { + public synchronized void generateFileFormat(OutputStream out, List things, boolean hideDefaultChannels, + boolean hideDefaultParameters) { ThingModel model = ThingFactory.eINSTANCE.createThingModel(); Set handledThings = new HashSet<>(); for (Thing thing : things) { if (handledThings.contains(thing)) { continue; } - model.getThings() - .add(buildModelThing(thing, hideDefaultParameters, things.size() > 1, true, things, handledThings)); + model.getThings().add(buildModelThing(thing, hideDefaultChannels, hideDefaultParameters, things.size() > 1, + true, things, handledThings)); } // Double quotes are unexpectedly generated in thing UID when the segment contains a -. // Fix that by removing these double quotes. Requires to first build the generated syntax as a String @@ -97,8 +103,8 @@ public synchronized void generateFileFormat(OutputStream out, List things } } - private ModelThing buildModelThing(Thing thing, boolean hideDefaultParameters, boolean preferPresentationAsTree, - boolean topLevel, List onlyThings, Set handledThings) { + private ModelThing buildModelThing(Thing thing, boolean hideDefaultChannels, boolean hideDefaultParameters, + boolean preferPresentationAsTree, boolean topLevel, List onlyThings, Set handledThings) { ModelThing model; ModelBridge modelBridge; List childThings = getChildThings(thing, onlyThings); @@ -138,13 +144,13 @@ private ModelThing buildModelThing(Thing thing, boolean hideDefaultParameters, b modelBridge.setThingsHeader(false); for (Thing child : childThings) { if (!handledThings.contains(child)) { - modelBridge.getThings() - .add(buildModelThing(child, hideDefaultParameters, true, false, onlyThings, handledThings)); + modelBridge.getThings().add(buildModelThing(child, hideDefaultChannels, hideDefaultParameters, true, + false, onlyThings, handledThings)); } } } - List channels = getNonDefaultChannels(thing); + List channels = hideDefaultChannels ? getNonDefaultChannels(thing) : thing.getChannels(); model.setChannelsHeader(!channels.isEmpty()); for (Channel channel : channels) { model.getChannels().add(buildModelChannel(channel, hideDefaultParameters)); @@ -197,4 +203,21 @@ private ModelChannel buildModelChannel(Channel channel, boolean hideDefaultParam } return property; } + + @Override + public String getFileFormatParser() { + return "DSL"; + } + + @Override + public boolean parseFileFormat(String syntax, List things, List errors, List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + String modelName = modelRepository.createTemporaryModel("things", inputStream, errors, warnings); + if (modelName != null) { + things.addAll(thingProvider.getThingsFromModel(modelName)); + modelRepository.removeModel(modelName); + return true; + } + return false; + } } diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelRepository.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelRepository.java index bec0466c5b1..52f9a016c65 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelRepository.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/YamlModelRepository.java @@ -12,16 +12,19 @@ */ package org.openhab.core.model.yaml; +import java.io.InputStream; import java.io.OutputStream; import java.util.List; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; /** * The {@link YamlModelRepository} defines methods to update elements in a YAML model. * * @author Jan N. Klug - Initial contribution * @author Laurent Garnier - Added methods refreshModelElements and generateSyntaxFromElements + * @author Laurent Garnier - Added methods createTemporaryModel and removeTemporaryModel */ @NonNullByDefault public interface YamlModelRepository { @@ -46,4 +49,25 @@ public interface YamlModelRepository { * @param elements the list of elements to includ */ void generateSyntaxFromElements(OutputStream out, List elements); + + /** + * Creates a temporary model in the repository + * + * A temporary model is not attached to a file on disk. + * A temporary model will be loaded without impacting any object registry. + * + * @param inputStream an input stream with the model's content + * @param errors the list to be used to fill the errors + * @param warnings the list to be used to fill the warnings + * @return the created model name if it was successfully processed, null otherwise + */ + @Nullable + String createTemporaryModel(InputStream inputStream, List errors, List warnings); + + /** + * Removes a temporary model from the repository + * + * @param modelName the name of the model to be removed + */ + void removeTemporaryModel(String modelName); } diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java index d120bf5fc0b..87348b38eec 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java @@ -15,6 +15,7 @@ import static org.openhab.core.service.WatchService.Kind.CREATE; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.lang.reflect.InvocationTargetException; import java.nio.file.FileVisitResult; @@ -79,6 +80,7 @@ * @author Laurent Garnier - Added basic version management * @author Laurent Garnier - Added methods refreshModelElements and generateSyntaxFromElements + new parameters * for method isValid + * @author Laurent Garnier - Added methods createTemporaryModel and removeTemporaryModel */ @NonNullByDefault @Component(immediate = true) @@ -86,6 +88,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener, private static final int DEFAULT_MODEL_VERSION = 2; private static final String VERSION = "version"; private static final String READ_ONLY = "readOnly"; + private static final String PREFIX_TMP_MODEL = "tmp_"; private static final Set KNOWN_ELEMENTS = Set.of( // getElementName(YamlSemanticTagDTO.class), // "tags" getElementName(YamlThingDTO.class) // "things" @@ -101,6 +104,8 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener, // all model nodes, ordered by model name (full path as string) and type private final Map modelCache = new ConcurrentHashMap<>(); + private int counter; + @Activate public YamlModelRepositoryImpl(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) { YAMLFactory yamlFactory = YAMLFactory.builder() // @@ -153,7 +158,6 @@ public void deactivate() { // The method is "synchronized" to avoid concurrent files processing @Override - @SuppressWarnings({ "rawtypes", "unchecked" }) public synchronized void processWatchEvent(Kind kind, Path path) { Path fullPath = watchPath.resolve(path); String modelName = path.toString(); @@ -162,156 +166,164 @@ public synchronized void processWatchEvent(Kind kind, Path path) { return; } + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); try { if (kind == WatchService.Kind.DELETE) { removeModel(modelName); } else if (!Files.isHidden(fullPath) && Files.isReadable(fullPath) && !Files.isDirectory(fullPath)) { - JsonNode fileContent = objectMapper.readTree(fullPath.toFile()); - - // check version - JsonNode versionNode = fileContent.get(VERSION); - if (versionNode == null || !versionNode.canConvertToInt()) { - logger.warn("Version is missing or not a number in model {}. Ignoring it.", modelName); - removeModel(modelName); - return; - } - int modelVersion = versionNode.asInt(); - if (modelVersion < 1 || modelVersion > DEFAULT_MODEL_VERSION) { - logger.warn( - "Model {} has version {}, but only versions between 1 and {} are supported. Ignoring it.", - modelName, modelVersion, DEFAULT_MODEL_VERSION); - removeModel(modelName); - return; - } - if (kind == Kind.CREATE) { - logger.info("Adding YAML model {}", modelName); - } else { - logger.info("Updating YAML model {}", modelName); - } - JsonNode readOnlyNode = fileContent.get(READ_ONLY); - boolean readOnly = readOnlyNode == null || readOnlyNode.asBoolean(false); - - YamlModelWrapper model = Objects.requireNonNull( - modelCache.computeIfAbsent(modelName, k -> new YamlModelWrapper(modelVersion, readOnly))); - - List newElementNames = new ArrayList<>(); - // get sub-elements - Iterator> it = fileContent.fields(); - while (it.hasNext()) { - Map.Entry element = it.next(); - String elementName = element.getKey(); - if (elementName.equals(VERSION) || elementName.equals(READ_ONLY)) { - continue; - } + processModelContent(modelName, kind, objectMapper.readTree(fullPath.toFile()), errors, warnings); + } else { + logger.trace("Ignored {}", fullPath); + } + } catch (IOException e) { + errors.add("Failed to process model: %s".formatted(e.getMessage())); + } + errors.forEach(error -> { + logger.warn("YAML model {}: {}", modelName, error); + }); + warnings.forEach(warning -> { + logger.info("YAML model {}: {}", modelName, warning); + }); + } - newElementNames.add(elementName); - JsonNode node = element.getValue(); + @SuppressWarnings({ "rawtypes", "unchecked" }) + private boolean processModelContent(String modelName, Kind kind, JsonNode fileContent, List errors, + List warnings) { + // check version + JsonNode versionNode = fileContent.get(VERSION); + if (versionNode == null || !versionNode.canConvertToInt()) { + errors.add("version is missing or not a number. Ignoring model."); + removeModel(modelName); + return false; + } + int modelVersion = versionNode.asInt(); + if (modelVersion < 1 || modelVersion > DEFAULT_MODEL_VERSION) { + errors.add("model has version %d, but only versions between 1 and %d are supported. Ignoring model." + .formatted(modelVersion, DEFAULT_MODEL_VERSION)); + removeModel(modelName); + return false; + } + if (kind == Kind.CREATE) { + logger.info("Adding YAML model {}", modelName); + } else { + logger.info("Updating YAML model {}", modelName); + } + JsonNode readOnlyNode = fileContent.get(READ_ONLY); + boolean readOnly = readOnlyNode == null || readOnlyNode.asBoolean(false); - List newNodeV1Elements = new ArrayList<>(); - JsonNode newNodeElements = null; + YamlModelWrapper model = Objects.requireNonNull( + modelCache.computeIfAbsent(modelName, k -> new YamlModelWrapper(modelVersion, readOnly))); + + boolean valid = true; + + List newElementNames = new ArrayList<>(); + // get sub-elements + Iterator> it = fileContent.fields(); + while (it.hasNext()) { + Map.Entry element = it.next(); + String elementName = element.getKey(); + if (elementName.equals(VERSION) || elementName.equals(READ_ONLY)) { + continue; + } - if (modelVersion == 1) { - if (!node.isArray()) { - // all processable sub-elements are arrays - logger.trace("Element {} in model {} is not an array, ignoring it", elementName, modelName); - continue; - } - node.elements().forEachRemaining(newNodeV1Elements::add); - } else { - if (!node.isContainerNode() || node.isArray()) { - // all processable sub-elements are container nodes (not array) - logger.trace("Element {} in model {} is not a container object, ignoring it", elementName, - modelName); - continue; - } - newNodeElements = node; - } + newElementNames.add(elementName); + JsonNode node = element.getValue(); - List oldNodeV1Elements = model.getNodesV1().getOrDefault(elementName, List.of()); - JsonNode oldNodeElements = model.getNodes().get(elementName); - - for (YamlModelListener elementListener : getElementListeners(elementName, modelVersion)) { - Class elementClass = elementListener.getElementClass(); - - List errors = new ArrayList<>(); - List warnings = new ArrayList<>(); - - Map oldElements = listToMap( - parseJsonNodes(oldNodeV1Elements, oldNodeElements, elementClass, null, null)); - Map newElements = listToMap( - parseJsonNodes(newNodeV1Elements, newNodeElements, elementClass, errors, warnings)); - - errors.forEach(error -> { - logger.warn("YAML model {}: {}", modelName, error); - }); - warnings.forEach(warning -> { - logger.info("YAML model {}: {}", modelName, warning); - }); - - List addedElements = newElements.values().stream() - .filter(e -> !oldElements.containsKey(e.getId())).toList(); - List removedElements = oldElements.values().stream() - .filter(e -> !newElements.containsKey(e.getId())).toList(); - List updatedElements = newElements.values().stream().filter( - e -> oldElements.containsKey(e.getId()) && !e.equals(oldElements.get(e.getId()))) - .toList(); - - if (elementListener.isDeprecated() - && (!addedElements.isEmpty() || !updatedElements.isEmpty())) { - logger.warn( - "Element {} in model {} version {} is still supported but deprecated, please consider migrating your model to a more recent version", - elementName, modelName, modelVersion); - } + List newNodeV1Elements = new ArrayList<>(); + JsonNode newNodeElements = null; - if (!addedElements.isEmpty()) { - elementListener.addedModel(modelName, addedElements); - } - if (!removedElements.isEmpty()) { - elementListener.removedModel(modelName, removedElements); - } - if (!updatedElements.isEmpty()) { - elementListener.updatedModel(modelName, updatedElements); - } - } + if (modelVersion == 1) { + if (!node.isArray()) { + // all processable sub-elements are arrays + logger.trace("Element {} in model {} is not an array, ignoring it", elementName, modelName); + continue; + } + node.elements().forEachRemaining(newNodeV1Elements::add); + } else { + if (!node.isContainerNode() || node.isArray()) { + // all processable sub-elements are container nodes (not array) + logger.trace("Element {} in model {} is not a container object, ignoring it", elementName, + modelName); + continue; + } + newNodeElements = node; + } - // replace cache - if (modelVersion == 1) { - model.getNodesV1().put(elementName, newNodeV1Elements); - } else { - model.getNodes().put(elementName, newNodeElements); - } + List oldNodeV1Elements = model.getNodesV1().getOrDefault(elementName, List.of()); + JsonNode oldNodeElements = model.getNodes().get(elementName); + + for (YamlModelListener elementListener : getElementListeners(elementName, modelVersion)) { + Class elementClass = elementListener.getElementClass(); + + List errors2 = new ArrayList<>(); + List warnings2 = new ArrayList<>(); + + Map oldElements = listToMap( + parseJsonNodes(oldNodeV1Elements, oldNodeElements, elementClass, null, null)); + Map newElements = listToMap( + parseJsonNodes(newNodeV1Elements, newNodeElements, elementClass, errors2, warnings2)); + valid &= errors2.isEmpty(); + errors.addAll(errors2); + warnings.addAll(warnings2); + + List addedElements = newElements.values().stream().filter(e -> !oldElements.containsKey(e.getId())) + .toList(); + List removedElements = oldElements.values().stream().filter(e -> !newElements.containsKey(e.getId())) + .toList(); + List updatedElements = newElements.values().stream() + .filter(e -> oldElements.containsKey(e.getId()) && !e.equals(oldElements.get(e.getId()))) + .toList(); + + if (elementListener.isDeprecated() && (!addedElements.isEmpty() || !updatedElements.isEmpty())) { + warnings.add( + "Element %s in version %d is still supported but deprecated, please consider migrating your model to a more recent version" + .formatted(elementName, modelVersion)); } - // remove removed elements - if (modelVersion == 1) { - model.getNodesV1().keySet().stream().filter(e -> !newElementNames.contains(e)) - .forEach(removedElement -> { - List removedNodes = model.getNodesV1().remove(removedElement); - getElementListeners(removedElement, modelVersion).forEach(listener -> { - List removedElements = parseJsonNodesV1(removedNodes, listener.getElementClass(), - null, null); - listener.removedModel(modelName, removedElements); - }); - }); - } else { - model.getNodes().keySet().stream().filter(e -> !newElementNames.contains(e)) - .forEach(removedElement -> { - JsonNode removedNode = model.getNodes().remove(removedElement); - getElementListeners(removedElement, modelVersion).forEach(listener -> { - List removedElements = parseJsonMapNode(removedNode, listener.getElementClass(), - null, null); - listener.removedModel(modelName, removedElements); - }); - }); + if (!addedElements.isEmpty()) { + elementListener.addedModel(modelName, addedElements); } + if (!removedElements.isEmpty()) { + elementListener.removedModel(modelName, removedElements); + } + if (!updatedElements.isEmpty()) { + elementListener.updatedModel(modelName, updatedElements); + } + } - checkElementNames(modelName, model); + // replace cache + if (modelVersion == 1) { + model.getNodesV1().put(elementName, newNodeV1Elements); } else { - logger.trace("Ignored {}", fullPath); + model.getNodes().put(elementName, newNodeElements); } - } catch (IOException e) { - logger.warn("Failed to process model {}: {}", modelName, e.getMessage()); } + + // remove removed elements + if (modelVersion == 1) { + model.getNodesV1().keySet().stream().filter(e -> !newElementNames.contains(e)).forEach(removedElement -> { + List removedNodes = model.getNodesV1().remove(removedElement); + if (removedNodes != null) { + getElementListeners(removedElement, modelVersion).forEach(listener -> { + List removedElements = parseJsonNodesV1(removedNodes, listener.getElementClass(), null, null); + listener.removedModel(modelName, removedElements); + }); + } + }); + } else { + model.getNodes().keySet().stream().filter(e -> !newElementNames.contains(e)).forEach(removedElement -> { + JsonNode removedNode = model.getNodes().remove(removedElement); + getElementListeners(removedElement, modelVersion).forEach(listener -> { + List removedElements = parseJsonMapNode(removedNode, listener.getElementClass(), null, null); + listener.removedModel(modelName, removedElements); + }); + }); + } + + checkElementNames(modelName, model, warnings); + + return valid; } private void removeModel(String modelName) { @@ -372,11 +384,11 @@ public void addYamlModelListener(YamlModelListener listen errors.forEach(error -> { logger.warn("YAML model {}: {}", modelName, error); }); + listener.addedModel(modelName, modelElements); + checkElementNames(modelName, model, warnings); warnings.forEach(warning -> { logger.info("YAML model {}: {}", modelName, warning); }); - listener.addedModel(modelName, modelElements); - checkElementNames(modelName, model); }); } @@ -388,13 +400,13 @@ public void removeYamlModelListener(YamlModelListener lis }); } - private void checkElementNames(String modelName, YamlModelWrapper model) { + private void checkElementNames(String modelName, YamlModelWrapper model, List warnings) { Set elementListenerNames = elementListeners.keySet(); if (elementListenerNames.containsAll(KNOWN_ELEMENTS)) { Set modelElementNames = model.getVersion() == 1 ? model.getNodesV1().keySet() : model.getNodes().keySet(); modelElementNames.stream().filter(e -> !KNOWN_ELEMENTS.contains(e)).forEach(unknownElement -> { - logger.warn("Element '{}' in model {} is unknown.", unknownElement, modelName); + warnings.add("Element '%s' is unknown.".formatted(unknownElement)); }); } } @@ -598,7 +610,10 @@ private void writeModel(String modelName) { logger.warn("Failed to write model {} to disk because it is not known.", modelName); return; } - + if (isTemporaryModel(modelName)) { + logger.warn("Failed to write model {} to disk because it is a temporary model.", modelName); + return; + } if (model.isReadOnly()) { logger.warn("Failed to write model {} to disk because it is marked as read-only.", modelName); return; @@ -639,6 +654,32 @@ private void writeModel(String modelName) { } } + @Override + public synchronized @Nullable String createTemporaryModel(InputStream inputStream, List errors, + List warnings) { + String modelName = "%smodel_%d.yaml".formatted(PREFIX_TMP_MODEL, ++counter); + boolean valid; + try { + valid = processModelContent(modelName, Kind.CREATE, objectMapper.readTree(inputStream), errors, warnings); + } catch (IOException e) { + logger.warn("Failed to process model {}: {}", modelName, e.getMessage()); + errors.add("Failed to process model: %s".formatted(e.getMessage())); + valid = false; + } + return valid ? modelName : null; + } + + @Override + public void removeTemporaryModel(String modelName) { + if (isTemporaryModel(modelName)) { + removeModel(modelName); + } + } + + public static boolean isTemporaryModel(String modelName) { + return modelName.startsWith(PREFIX_TMP_MODEL); + } + @Override public void generateSyntaxFromElements(OutputStream out, List elements) { // create the model diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/things/YamlThingProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/things/YamlThingProvider.java index b69de8a6bd8..37ad0ceaf4b 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/things/YamlThingProvider.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/things/YamlThingProvider.java @@ -35,6 +35,7 @@ import org.openhab.core.model.yaml.YamlElementName; import org.openhab.core.model.yaml.YamlModelListener; import org.openhab.core.model.yaml.YamlModelRepository; +import org.openhab.core.model.yaml.internal.YamlModelRepositoryImpl; import org.openhab.core.service.ReadyMarker; import org.openhab.core.service.ReadyService; import org.openhab.core.service.StartLevelService; @@ -120,7 +121,8 @@ public void run() { thingsForModel.remove(oldThing); thingsForModel.add(newThing); logger.debug("Refreshing thing \'{}\' after successful retry", newThing.getUID()); - if (!ThingHelper.equals(oldThing, newThing)) { + if (!ThingHelper.equals(oldThing, newThing) + && !YamlModelRepositoryImpl.isTemporaryModel(entry.getKey())) { notifyListenersAboutUpdatedElement(oldThing, newThing); } break; @@ -175,7 +177,13 @@ public void deactivate() { @Override public Collection getAll() { - return thingsMap.values().stream().flatMap(list -> list.stream()).toList(); + // Ignore temporary models + return thingsMap.keySet().stream().filter(name -> !YamlModelRepositoryImpl.isTemporaryModel(name)) + .map(name -> thingsMap.getOrDefault(name, List.of())).flatMap(list -> list.stream()).toList(); + } + + public Collection getAllFromModel(String modelName) { + return thingsMap.getOrDefault(modelName, List.of()); } @Override @@ -201,7 +209,9 @@ public void addedModel(String modelName, Collection elements) { modelThings.addAll(added); added.forEach(t -> { logger.debug("model {} added thing {}", modelName, t.getUID()); - notifyListenersAboutAddedElement(t); + if (!YamlModelRepositoryImpl.isTemporaryModel(modelName)) { + notifyListenersAboutAddedElement(t); + } }); } @@ -215,11 +225,15 @@ public void updatedModel(String modelName, Collection elements) { modelThings.remove(oldThing); modelThings.add(t); logger.debug("model {} updated thing {}", modelName, t.getUID()); - notifyListenersAboutUpdatedElement(oldThing, t); + if (!YamlModelRepositoryImpl.isTemporaryModel(modelName)) { + notifyListenersAboutUpdatedElement(oldThing, t); + } }, () -> { modelThings.add(t); logger.debug("model {} added thing {}", modelName, t.getUID()); - notifyListenersAboutAddedElement(t); + if (!YamlModelRepositoryImpl.isTemporaryModel(modelName)) { + notifyListenersAboutAddedElement(t); + } }); }); } @@ -233,7 +247,9 @@ public void removedModel(String modelName, Collection elements) { modelThings.stream().filter(th -> th.getUID().equals(t.getUID())).findFirst().ifPresentOrElse(oldThing -> { modelThings.remove(oldThing); logger.debug("model {} removed thing {}", modelName, t.getUID()); - notifyListenersAboutRemovedElement(oldThing); + if (!YamlModelRepositoryImpl.isTemporaryModel(modelName)) { + notifyListenersAboutRemovedElement(oldThing); + } }, () -> logger.debug("model {} thing {} not found", modelName, t.getUID())); }); } diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/things/fileconverter/YamlThingFileConverter.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/things/fileconverter/YamlThingFileConverter.java index 350a7bd7d37..522779c3487 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/things/fileconverter/YamlThingFileConverter.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/things/fileconverter/YamlThingFileConverter.java @@ -12,6 +12,7 @@ */ package org.openhab.core.model.yaml.internal.things.fileconverter; +import java.io.ByteArrayInputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -26,12 +27,14 @@ import org.openhab.core.model.yaml.YamlModelRepository; import org.openhab.core.model.yaml.internal.things.YamlChannelDTO; import org.openhab.core.model.yaml.internal.things.YamlThingDTO; +import org.openhab.core.model.yaml.internal.things.YamlThingProvider; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Channel; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.fileconverter.AbstractThingFileGenerator; import org.openhab.core.thing.fileconverter.ThingFileGenerator; +import org.openhab.core.thing.fileconverter.ThingFileParser; import org.openhab.core.thing.type.ChannelKind; import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeRegistry; @@ -48,20 +51,22 @@ * @author Laurent Garnier - Initial contribution */ @NonNullByDefault -@Component(immediate = true, service = ThingFileGenerator.class) -public class YamlThingFileConverter extends AbstractThingFileGenerator { +@Component(immediate = true, service = { ThingFileGenerator.class, ThingFileParser.class }) +public class YamlThingFileConverter extends AbstractThingFileGenerator implements ThingFileParser { private final YamlModelRepository modelRepository; + private final YamlThingProvider thingProvider; private final LocaleProvider localeProvider; @Activate public YamlThingFileConverter(final @Reference YamlModelRepository modelRepository, - final @Reference ThingTypeRegistry thingTypeRegistry, + final @Reference YamlThingProvider thingProvider, final @Reference ThingTypeRegistry thingTypeRegistry, final @Reference ChannelTypeRegistry channelTypeRegistry, final @Reference ConfigDescriptionRegistry configDescRegistry, final @Reference LocaleProvider localeProvider) { super(thingTypeRegistry, channelTypeRegistry, configDescRegistry); this.modelRepository = modelRepository; + this.thingProvider = thingProvider; this.localeProvider = localeProvider; } @@ -71,15 +76,16 @@ public String getFileFormatGenerator() { } @Override - public synchronized void generateFileFormat(OutputStream out, List things, boolean hideDefaultParameters) { + public synchronized void generateFileFormat(OutputStream out, List things, boolean hideDefaultChannels, + boolean hideDefaultParameters) { List elements = new ArrayList<>(); things.forEach(thing -> { - elements.add(buildThingDTO(thing, hideDefaultParameters)); + elements.add(buildThingDTO(thing, hideDefaultChannels, hideDefaultParameters)); }); modelRepository.generateSyntaxFromElements(out, elements); } - private YamlThingDTO buildThingDTO(Thing thing, boolean hideDefaultParameters) { + private YamlThingDTO buildThingDTO(Thing thing, boolean hideDefaultChannels, boolean hideDefaultParameters) { YamlThingDTO dto = new YamlThingDTO(); dto.uid = thing.getUID().getAsString(); dto.isBridge = thing instanceof Bridge ? true : null; @@ -102,7 +108,8 @@ private YamlThingDTO buildThingDTO(Thing thing, boolean hideDefaultParameters) { dto.config = config.isEmpty() ? null : config; Map channels = new LinkedHashMap<>(); - getNonDefaultChannels(thing).forEach(channel -> { + List channelList = hideDefaultChannels ? getNonDefaultChannels(thing) : thing.getChannels(); + channelList.forEach(channel -> { channels.put(channel.getUID().getId(), buildChannelDTO(channel, hideDefaultParameters)); }); dto.channels = channels.isEmpty() ? null : channels; @@ -140,4 +147,21 @@ private YamlChannelDTO buildChannelDTO(Channel channel, boolean hideDefaultParam return dto; } + + @Override + public String getFileFormatParser() { + return "YAML"; + } + + @Override + public boolean parseFileFormat(String syntax, List things, List errors, List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + String modelName = modelRepository.createTemporaryModel(inputStream, errors, warnings); + if (modelName != null) { + things.addAll(thingProvider.getAllFromModel(modelName)); + modelRepository.removeTemporaryModel(modelName); + return true; + } + return false; + } } diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileGenerator.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileGenerator.java index fe5d0ec53c7..6247b2673b7 100644 --- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileGenerator.java +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileGenerator.java @@ -38,7 +38,9 @@ public interface ThingFileGenerator { * * @param out the output stream to write the generated syntax to * @param things the things + * @param hideDefaultChannels true to hide the non extensible channels having a default configuration * @param hideDefaultParameters true to hide the configuration parameters having the default value */ - void generateFileFormat(OutputStream out, List things, boolean hideDefaultParameters); + void generateFileFormat(OutputStream out, List things, boolean hideDefaultChannels, + boolean hideDefaultParameters); } diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileParser.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileParser.java new file mode 100644 index 00000000000..50490bc93fc --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileParser.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.thing.fileconverter; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.Thing; + +/** + * {@link ThingFileParser} is the interface to implement by any file parser for {@link Thing} object. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public interface ThingFileParser { + + /** + * Returns the format of the syntax. + * + * @return the syntax format + */ + String getFileFormatParser(); + + /** + * Parse the provided syntax in file format and return the corresponding {@link Thing} objects without impacting + * the thing registry. + * + * @param syntax the syntax in file format + * @param things the list of {@link Thing} to fill + * @param errors the list to be used to fill the errors + * @param warnings the list to be used to fill the warnings + * @return true if the parsing succeeded without errors + */ + boolean parseFileFormat(String syntax, List things, List errors, List warnings); +} From 36d33e1a42dcc1b434ad5bcb69aa40cb56f7ea17 Mon Sep 17 00:00:00 2001 From: Laurent Garnier Date: Sat, 3 May 2025 15:13:39 +0200 Subject: [PATCH 2/3] Fix typo Signed-off-by: Laurent Garnier --- .../internal/fileformat/FileFormatResource.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java index fd001ddbb9d..93bed765041 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java @@ -402,20 +402,19 @@ public Response parse(final @Context HttpHeaders httpHeaders, @RequestBody(description = "file format syntax", required = true, content = { @Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = DSL_THINGS_EXAMPLE)), @Content(mediaType = "application/yaml", schema = @Schema(example = YAML_THINGS_EXAMPLE)) }) String input) { - String contentTypetHeader = httpHeaders.getHeaderString(HttpHeaders.CONTENT_TYPE); - String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); - logger.debug("transform: contentType = {}, mediaType = {}", contentTypetHeader, acceptHeader); + String contentTypeHeader = httpHeaders.getHeaderString(HttpHeaders.CONTENT_TYPE); + logger.debug("parse: contentType = {}", contentTypeHeader); // First parse the input List things = new ArrayList<>(); List errors = new ArrayList<>(); List warnings = new ArrayList<>(); - ThingFileParser thingParser = getThingFileParser(contentTypetHeader); - switch (contentTypetHeader) { + ThingFileParser thingParser = getThingFileParser(contentTypeHeader); + switch (contentTypeHeader) { case "text/vnd.openhab.dsl.thing": if (thingParser == null) { return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) - .entity("Unsupported content type '" + contentTypetHeader + "'!").build(); + .entity("Unsupported content type '" + contentTypeHeader + "'!").build(); } else if (!thingParser.parseFileFormat(input, things, errors, warnings)) { return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); } else if (things.isEmpty()) { @@ -429,7 +428,7 @@ public Response parse(final @Context HttpHeaders httpHeaders, break; default: return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) - .entity("Unsupported content type '" + contentTypetHeader + "'!").build(); + .entity("Unsupported content type '" + contentTypeHeader + "'!").build(); } return Response.ok(convertToFileFormatDTO(things, warnings)).build(); From dd13efd59cbd52795ba6254ae2b6c072179f7e03 Mon Sep 17 00:00:00 2001 From: Laurent Garnier Date: Sun, 4 May 2025 11:48:26 +0200 Subject: [PATCH 3/3] [REST] Extend the file format conversion APIs to support DSL items Related to #4585 Signed-off-by: Laurent Garnier --- .../fileformat/FileFormatChannelLinkDTO.java | 31 +++++ .../rest/core/fileformat/FileFormatDTO.java | 1 + .../core/fileformat/FileFormatItemDTO.java | 47 +++++++ .../fileformat/FileFormatItemDTOMapper.java | 126 ++++++++++++++++++ .../fileformat/FileFormatResource.java | 102 +++++++++++++- .../core/internal/ModelRepositoryImpl.java | 2 +- .../item/internal/GenericItemProvider.java | 25 +++- .../internal/GenericMetadataProvider.java | 51 +++++-- .../fileconverter/DslItemFileConverter.java | 48 ++++++- .../items/fileconverter/ItemFileParser.java | 49 +++++++ 10 files changed, 457 insertions(+), 25 deletions(-) create mode 100644 bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatChannelLinkDTO.java create mode 100644 bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatItemDTO.java create mode 100644 bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatItemDTOMapper.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileParser.java diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatChannelLinkDTO.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatChannelLinkDTO.java new file mode 100644 index 00000000000..e84d1f70687 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatChannelLinkDTO.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.core.fileformat; + +import java.util.Map; + +/** + * This is a data transfer object to serialize a channel link for an item contained in a file format. + * + * @author Laurent Garnier - Initial contribution + */ +public class FileFormatChannelLinkDTO { + + public String channelUID; + public Map configuration; + + public FileFormatChannelLinkDTO(String channelUID, Map configuration) { + this.channelUID = channelUID; + this.configuration = configuration; + } +} diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java index 27d9076ea96..f4f80675d48 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java @@ -24,5 +24,6 @@ */ public class FileFormatDTO { + public List items; public List things; } diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatItemDTO.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatItemDTO.java new file mode 100644 index 00000000000..557ff684046 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatItemDTO.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.core.fileformat; + +import java.util.List; +import java.util.Map; + +import org.openhab.core.items.dto.GroupFunctionDTO; +import org.openhab.core.items.dto.GroupItemDTO; +import org.openhab.core.items.dto.ItemDTO; +import org.openhab.core.items.dto.MetadataDTO; + +/** + * This is a data transfer object to serialize an item contained in a file format. + * + * @author Laurent Garnier - Initial contribution + */ +public class FileFormatItemDTO extends ItemDTO { + + public String groupType; + public GroupFunctionDTO function; + public Map metadata; + public List channelLinks; + + public FileFormatItemDTO(ItemDTO itemDTO, boolean isGroup) { + this.type = itemDTO.type; + this.name = itemDTO.name; + this.label = itemDTO.label; + this.category = itemDTO.category; + this.tags = itemDTO.tags; + this.groupNames = itemDTO.groupNames; + if (isGroup) { + this.groupType = ((GroupItemDTO) itemDTO).groupType; + this.function = ((GroupItemDTO) itemDTO).function; + } + } +} diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatItemDTOMapper.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatItemDTOMapper.java new file mode 100644 index 00000000000..5535f1ed59c --- /dev/null +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatItemDTOMapper.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.core.fileformat; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.items.GroupItem; +import org.openhab.core.items.Item; +import org.openhab.core.items.ItemBuilderFactory; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.items.dto.GroupItemDTO; +import org.openhab.core.items.dto.ItemDTO; +import org.openhab.core.items.dto.ItemDTOMapper; +import org.openhab.core.items.dto.MetadataDTO; + +/** + * The {@link FileFormatItemDTOMapper} is a utility class to map items into file format item data transfer objects + * (DTOs). + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class FileFormatItemDTOMapper { + + /** + * Maps item into file format item DTO object. + * + * @param item the item + * @param metadata some metadata (including channel links) + * @return file format item DTO object + */ + public static FileFormatItemDTO map(Item item, List metadata) { + ItemDTO itemDto = ItemDTOMapper.map(item); + FileFormatItemDTO dto = new FileFormatItemDTO(itemDto, itemDto instanceof GroupItemDTO); + + List hannelLinks = new ArrayList<>(); + Map metadataDTO = new LinkedHashMap<>(); + metadata.forEach(md -> { + if (item.getName().equals(md.getUID().getItemName())) { + if ("channel".equals(md.getUID().getNamespace())) { + hannelLinks.add(new FileFormatChannelLinkDTO(md.getValue(), + md.getConfiguration().isEmpty() ? null : md.getConfiguration())); + } else { + MetadataDTO mdDTO = new MetadataDTO(); + mdDTO.value = md.getValue(); + mdDTO.config = md.getConfiguration().isEmpty() ? null : md.getConfiguration(); + metadataDTO.put(md.getUID().getNamespace(), mdDTO); + } + } + }); + if (!hannelLinks.isEmpty()) { + dto.channelLinks = hannelLinks; + } + if (!metadataDTO.isEmpty()) { + dto.metadata = metadataDTO; + } + + return dto; + } + + /** + * Maps file format item DTO object into item. + * + * @param dto the file format item DTO object + * @param itemBuilderFactory the item builder factory + * @return item + */ + public static @Nullable Item map(FileFormatItemDTO dto, ItemBuilderFactory itemBuilderFactory) { + if (GroupItem.TYPE.equals(dto.type)) { + GroupItemDTO groupDto = new GroupItemDTO(); + groupDto.type = dto.type; + groupDto.name = dto.name; + groupDto.label = dto.label; + groupDto.category = dto.category; + groupDto.tags = dto.tags; + groupDto.groupNames = dto.groupNames; + groupDto.groupType = dto.groupType; + groupDto.function = dto.function; + return ItemDTOMapper.map(groupDto, itemBuilderFactory); + } + return ItemDTOMapper.map(dto, itemBuilderFactory); + } + + /** + * Maps file format item DTO object into a collection of metadata including channels links + * provided through the "channel" namespace. + * + * @param dto the file format item DTO object + * @return the collection of metadata + */ + public static Collection mapMetadata(FileFormatItemDTO dto) { + String name = dto.name; + Collection metadata = new ArrayList<>(); + if (dto.channelLinks != null) { + for (FileFormatChannelLinkDTO link : dto.channelLinks) { + MetadataKey key = new MetadataKey("channel", name); + metadata.add(new Metadata(key, link.channelUID, link.configuration)); + } + } + if (dto.metadata != null) { + for (Map.Entry md : dto.metadata.entrySet()) { + MetadataKey key = new MetadataKey(md.getKey(), name); + metadata.add(new Metadata(key, Objects.requireNonNull(md.getValue().value), md.getValue().config)); + } + } + return metadata; + } +} diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java index 93bed765041..675d013e602 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java @@ -53,14 +53,18 @@ import org.openhab.core.io.rest.RESTConstants; import org.openhab.core.io.rest.RESTResource; import org.openhab.core.io.rest.core.fileformat.FileFormatDTO; +import org.openhab.core.io.rest.core.fileformat.FileFormatItemDTO; +import org.openhab.core.io.rest.core.fileformat.FileFormatItemDTOMapper; import org.openhab.core.io.rest.core.fileformat.extendedFileFormatDTO; import org.openhab.core.items.GroupItem; import org.openhab.core.items.Item; +import org.openhab.core.items.ItemBuilderFactory; import org.openhab.core.items.ItemRegistry; import org.openhab.core.items.Metadata; import org.openhab.core.items.MetadataKey; import org.openhab.core.items.MetadataRegistry; import org.openhab.core.items.fileconverter.ItemFileGenerator; +import org.openhab.core.items.fileconverter.ItemFileParser; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -193,6 +197,7 @@ public class FileFormatResource implements RESTResource { private final Logger logger = LoggerFactory.getLogger(FileFormatResource.class); + private final ItemBuilderFactory itemBuilderFactory; private final ItemRegistry itemRegistry; private final MetadataRegistry metadataRegistry; private final ItemChannelLinkRegistry itemChannelLinkRegistry; @@ -202,11 +207,13 @@ public class FileFormatResource implements RESTResource { private final ChannelTypeRegistry channelTypeRegistry; private final ConfigDescriptionRegistry configDescRegistry; private final Map itemFileGenerators = new ConcurrentHashMap<>(); + private final Map itemFileParsers = new ConcurrentHashMap<>(); private final Map thingFileGenerators = new ConcurrentHashMap<>(); private final Map thingFileParsers = new ConcurrentHashMap<>(); @Activate public FileFormatResource(// + final @Reference ItemBuilderFactory itemBuilderFactory, // final @Reference ItemRegistry itemRegistry, // final @Reference MetadataRegistry metadataRegistry, final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, @@ -215,6 +222,7 @@ public FileFormatResource(// final @Reference ThingTypeRegistry thingTypeRegistry, // final @Reference ChannelTypeRegistry channelTypeRegistry, // final @Reference ConfigDescriptionRegistry configDescRegistry) { + this.itemBuilderFactory = itemBuilderFactory; this.itemRegistry = itemRegistry; this.metadataRegistry = metadataRegistry; this.itemChannelLinkRegistry = itemChannelLinkRegistry; @@ -238,6 +246,15 @@ protected void removeItemFileGenerator(ItemFileGenerator itemFileGenerator) { itemFileGenerators.remove(itemFileGenerator.getFileFormatGenerator()); } + @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.MULTIPLE) + protected void addItemFileParser(ItemFileParser itemFileParser) { + itemFileParsers.put(itemFileParser.getFileFormatParser(), itemFileParser); + } + + protected void removeItemFileParser(ItemFileParser itemFileParser) { + itemFileParsers.remove(itemFileParser.getFileFormatParser()); + } + @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.MULTIPLE) protected void addThingFileGenerator(ThingFileGenerator thingFileGenerator) { thingFileGenerators.put(thingFileGenerator.getFileFormatGenerator(), thingFileGenerator); @@ -338,30 +355,35 @@ public Response createFileFormatForThings(final @Context HttpHeaders httpHeaders @RolesAllowed({ Role.ADMIN }) @Path("/create") @Consumes({ MediaType.APPLICATION_JSON }) - @Produces({ "text/vnd.openhab.dsl.thing", "application/yaml" }) + @Produces({ "text/vnd.openhab.dsl.thing", "text/vnd.openhab.dsl.item", "application/yaml" }) @Operation(operationId = "create", summary = "Create file format.", security = { @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { @ApiResponse(responseCode = "200", description = "OK", content = { @Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = DSL_THINGS_EXAMPLE)), + @Content(mediaType = "text/vnd.openhab.dsl.item", schema = @Schema(example = DSL_ITEMS_EXAMPLE)), @Content(mediaType = "application/yaml", schema = @Schema(example = YAML_THINGS_EXAMPLE)) }), @ApiResponse(responseCode = "400", description = "Invalid JSON data."), @ApiResponse(responseCode = "415", description = "Unsupported media type.") }) public Response create(final @Context HttpHeaders httpHeaders, @DefaultValue("false") @QueryParam("hideDefaultParameters") @Parameter(description = "hide the configuration parameters having the default value") boolean hideDefaultParameters, @DefaultValue("false") @QueryParam("hideDefaultChannels") @Parameter(description = "hide the non extensible channels having a default configuration") boolean hideDefaultChannels, + @DefaultValue("false") @QueryParam("hideChannelLinksAndMetadata") @Parameter(description = "hide the channel links and metadata for items") boolean hideChannelLinksAndMetadata, @RequestBody(description = "JSON data", required = true, content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = FileFormatDTO.class))) FileFormatDTO data) { String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); logger.debug("create: mediaType = {}", acceptHeader); List things = new ArrayList<>(); + List items = new ArrayList<>(); + List metadata = new ArrayList<>(); List errors = new ArrayList<>(); - if (!convertFromFileFormatDTO(data, things, errors)) { + if (!convertFromFileFormatDTO(data, things, items, metadata, errors)) { return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); } String result = ""; ByteArrayOutputStream outputStream; ThingFileGenerator thingGenerator = getThingFileGenerator(acceptHeader); + ItemFileGenerator itemGenerator = getItemFileGenerator(acceptHeader); switch (acceptHeader) { case "text/vnd.openhab.dsl.thing": if (thingGenerator == null) { @@ -374,6 +396,18 @@ public Response create(final @Context HttpHeaders httpHeaders, thingGenerator.generateFileFormat(outputStream, things, hideDefaultChannels, hideDefaultParameters); result = new String(outputStream.toByteArray()); break; + case "text/vnd.openhab.dsl.item": + if (itemGenerator == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } else if (items.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST).entity("No item loaded from input").build(); + } + outputStream = new ByteArrayOutputStream(); + itemGenerator.generateFileFormat(outputStream, items, + hideChannelLinksAndMetadata ? List.of() : metadata, hideDefaultParameters); + result = new String(outputStream.toByteArray()); + break; case "application/yaml": if (thingGenerator != null) { outputStream = new ByteArrayOutputStream(); @@ -391,7 +425,7 @@ public Response create(final @Context HttpHeaders httpHeaders, @POST @RolesAllowed({ Role.ADMIN }) @Path("/parse") - @Consumes({ "text/vnd.openhab.dsl.thing", "application/yaml" }) + @Consumes({ "text/vnd.openhab.dsl.thing", "text/vnd.openhab.dsl.item", "application/yaml" }) @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "parse", summary = "Parse file format.", security = { @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { @@ -401,15 +435,19 @@ public Response create(final @Context HttpHeaders httpHeaders, public Response parse(final @Context HttpHeaders httpHeaders, @RequestBody(description = "file format syntax", required = true, content = { @Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = DSL_THINGS_EXAMPLE)), + @Content(mediaType = "text/vnd.openhab.dsl.item", schema = @Schema(example = DSL_ITEMS_EXAMPLE)), @Content(mediaType = "application/yaml", schema = @Schema(example = YAML_THINGS_EXAMPLE)) }) String input) { String contentTypeHeader = httpHeaders.getHeaderString(HttpHeaders.CONTENT_TYPE); logger.debug("parse: contentType = {}", contentTypeHeader); // First parse the input List things = new ArrayList<>(); + List items = new ArrayList<>(); + List metadata = new ArrayList<>(); List errors = new ArrayList<>(); List warnings = new ArrayList<>(); ThingFileParser thingParser = getThingFileParser(contentTypeHeader); + ItemFileParser itemParser = getItemFileParser(contentTypeHeader); switch (contentTypeHeader) { case "text/vnd.openhab.dsl.thing": if (thingParser == null) { @@ -421,6 +459,16 @@ public Response parse(final @Context HttpHeaders httpHeaders, return Response.status(Response.Status.BAD_REQUEST).entity("No thing loaded from input").build(); } break; + case "text/vnd.openhab.dsl.item": + if (itemParser == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported content type '" + contentTypeHeader + "'!").build(); + } else if (!itemParser.parseFileFormat(input, items, metadata, errors, warnings)) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } else if (items.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST).entity("No item loaded from input").build(); + } + break; case "application/yaml": if (thingParser != null && !thingParser.parseFileFormat(input, things, errors, warnings)) { return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); @@ -431,7 +479,7 @@ public Response parse(final @Context HttpHeaders httpHeaders, .entity("Unsupported content type '" + contentTypeHeader + "'!").build(); } - return Response.ok(convertToFileFormatDTO(things, warnings)).build(); + return Response.ok(convertToFileFormatDTO(things, items, metadata, warnings)).build(); } /* @@ -592,6 +640,14 @@ private Thing simulateThing(DiscoveryResult result, ThingType thingType) { }; } + private @Nullable ItemFileParser getItemFileParser(String contentType) { + return switch (contentType) { + case "text/vnd.openhab.dsl.item" -> itemFileParsers.get("DSL"); + case "application/yaml" -> itemFileParsers.get("YAML"); + default -> null; + }; + } + private @Nullable ThingFileParser getThingFileParser(String contentType) { return switch (contentType) { case "text/vnd.openhab.dsl.thing" -> thingFileParsers.get("DSL"); @@ -621,7 +677,8 @@ private List getThingsOrDiscoveryResult(List thingUIDs) { }).toList(); } - private boolean convertFromFileFormatDTO(FileFormatDTO data, List things, List errors) { + private boolean convertFromFileFormatDTO(FileFormatDTO data, List things, List items, + List metadata, List errors) { boolean ok = true; if (data.things != null) { for (ThingDTO thingBean : data.things) { @@ -681,10 +738,37 @@ private boolean convertFromFileFormatDTO(FileFormatDTO data, List things, things.add(thing); } } + if (data.items != null) { + for (FileFormatItemDTO itemData : data.items) { + String name = itemData.name; + if (name == null || name.isEmpty()) { + errors.add("Missing item name in items data!"); + ok = false; + continue; + } + + Item item; + try { + item = FileFormatItemDTOMapper.map(itemData, itemBuilderFactory); + if (item == null) { + errors.add("Invalid item type in items data!"); + ok = false; + continue; + } + } catch (IllegalArgumentException e) { + errors.add("Invalid item name in items data!"); + ok = false; + continue; + } + items.add(item); + metadata.addAll(FileFormatItemDTOMapper.mapMetadata(itemData)); + } + } return ok; } - private extendedFileFormatDTO convertToFileFormatDTO(List things, List warnings) { + private extendedFileFormatDTO convertToFileFormatDTO(List things, List items, List metadata, + List warnings) { extendedFileFormatDTO dto = new extendedFileFormatDTO(); dto.warnings = warnings.isEmpty() ? null : warnings; if (!things.isEmpty()) { @@ -693,6 +777,12 @@ private extendedFileFormatDTO convertToFileFormatDTO(List things, List(); + items.forEach(item -> { + dto.items.add(FileFormatItemDTOMapper.map(item, metadata)); + }); + } return dto; } diff --git a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java index b2953aec3d3..a78c9790d70 100644 --- a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java +++ b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java @@ -264,7 +264,7 @@ public void removeModelRepositoryChangeListener(ModelRepositoryChangeListener li return addOrRefreshModel(name, inputStream, errors, warnings) ? name : null; } - private boolean isTemporaryModel(String modelName) { + public static boolean isTemporaryModel(String modelName) { return modelName.startsWith(PREFIX_TMP_MODEL); } diff --git a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericItemProvider.java b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericItemProvider.java index 19380e68fa0..30f6fb3a383 100644 --- a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericItemProvider.java +++ b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericItemProvider.java @@ -69,7 +69,8 @@ * @author Thomas Eichstaedt-Engelen - Initial contribution */ @NonNullByDefault -@Component(service = { ItemProvider.class, StateDescriptionFragmentProvider.class }, immediate = true) +@Component(service = { ItemProvider.class, GenericItemProvider.class, + StateDescriptionFragmentProvider.class }, immediate = true) public class GenericItemProvider extends AbstractProvider implements ModelRepositoryChangeListener, ItemProvider, StateDescriptionFragmentProvider { @@ -170,6 +171,10 @@ public Collection getAll() { return items; } + public Collection getAllFromModel(String modelName) { + return itemsMap.getOrDefault(modelName, List.of()); + } + private Collection getItemsFromModel(String modelName) { logger.debug("Read items from model '{}'", modelName); @@ -206,7 +211,7 @@ private void processBindingConfigsFromModel(String modelName, EventType type) { // create items and read new binding configuration if (!EventType.REMOVED.equals(type)) { for (ModelItem modelItem : model.getItems()) { - genericMetaDataProvider.removeMetadataByItemName(modelItem.getName()); + genericMetaDataProvider.removeMetadataByItemName(modelName, modelItem.getName()); Item item = createItemFromModelItem(modelItem); if (item != null) { internalDispatchBindings(modelName, item, modelItem.getBindings()); @@ -389,7 +394,8 @@ private void internalDispatchBindings(@Nullable BindingConfigReader reader, Stri bindingType, item.getName(), e); } } else { - genericMetaDataProvider.addMetadata(bindingType, item.getName(), config, configuration.getProperties()); + genericMetaDataProvider.addMetadata(modelName, bindingType, item.getName(), config, + configuration.getProperties()); } } } @@ -416,7 +422,7 @@ public void modelChanged(String modelName, EventType type) { processBindingConfigsFromModel(modelName, type); for (Item oldItem : oldItems.values()) { if (!newItems.containsKey(oldItem.getName())) { - notifyAndCleanup(oldItem); + notifyAndCleanup(modelName, oldItem); } } break; @@ -425,17 +431,22 @@ public void modelChanged(String modelName, EventType type) { Collection itemsFromModel = getItemsFromModel(modelName); itemsMap.remove(modelName); for (Item item : itemsFromModel) { - notifyAndCleanup(item); + notifyAndCleanup(modelName, item); } break; } } } - private void notifyAndCleanup(Item oldItem) { + private void notifyAndCleanup(String modelName, Item oldItem) { notifyListenersAboutRemovedElement(oldItem); this.stateDescriptionFragments.remove(oldItem.getName()); - genericMetaDataProvider.removeMetadataByItemName(oldItem.getName()); + genericMetaDataProvider.removeMetadataByItemName(modelName, oldItem.getName()); + } + + public void processTemporaryModel(String modelName) { + itemsMap.put(modelName, getItemsFromModel(modelName)); + processBindingConfigsFromModel(modelName, EventType.ADDED); } protected boolean hasItemChanged(Item item1, Item item2) { diff --git a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericMetadataProvider.java b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericMetadataProvider.java index 00449502ad8..f35d98c807c 100644 --- a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericMetadataProvider.java +++ b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericMetadataProvider.java @@ -15,8 +15,10 @@ import static java.util.stream.Collectors.toSet; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -29,6 +31,7 @@ import org.openhab.core.items.MetadataKey; import org.openhab.core.items.MetadataPredicates; import org.openhab.core.items.MetadataProvider; +import org.openhab.core.model.core.internal.ModelRepositoryImpl; import org.osgi.service.component.annotations.Component; /** @@ -38,28 +41,31 @@ * methods. * * @author Kai Kreuzer - Initial contribution + * @author Laurent Garnier - Store metadata by model */ @Component(service = { MetadataProvider.class, GenericMetadataProvider.class }) @NonNullByDefault public class GenericMetadataProvider extends AbstractProvider implements MetadataProvider { - private final Set metadata = new HashSet<>(); + private final Map> metadata = new HashMap<>(); private final ReadWriteLock lock = new ReentrantReadWriteLock(true); /** * Adds metadata to this provider * + * @param modelName the model name * @param bindingType * @param itemName * @param configuration */ - public void addMetadata(String bindingType, String itemName, String value, + public void addMetadata(String modelName, String bindingType, String itemName, String value, @Nullable Map configuration) { MetadataKey key = new MetadataKey(bindingType, itemName); Metadata md = new Metadata(key, value, configuration); try { lock.writeLock().lock(); - metadata.add(md); + Set mdSet = Objects.requireNonNull(metadata.computeIfAbsent(modelName, k -> new HashSet<>())); + mdSet.add(md); } finally { lock.writeLock().unlock(); } @@ -75,8 +81,18 @@ public void removeMetadataByNamespace(String namespace) { Set toBeRemoved; try { lock.writeLock().lock(); - toBeRemoved = metadata.stream().filter(MetadataPredicates.hasNamespace(namespace)).collect(toSet()); - metadata.removeAll(toBeRemoved); + toBeRemoved = new HashSet<>(); + for (Map.Entry> entry : metadata.entrySet()) { + String modelName = entry.getKey(); + Set mdSet = entry.getValue(); + Set toBeRemovedForModel = mdSet.stream().filter(MetadataPredicates.hasNamespace(namespace)) + .collect(toSet()); + mdSet.removeAll(toBeRemovedForModel); + if (mdSet.isEmpty()) { + metadata.remove(modelName); + } + toBeRemoved.addAll(toBeRemovedForModel); + } } finally { lock.writeLock().unlock(); } @@ -86,14 +102,19 @@ public void removeMetadataByNamespace(String namespace) { /** * Removes all meta-data for a given item * + * @param modelName the model name * @param itemName the item name */ - public void removeMetadataByItemName(String itemName) { + public void removeMetadataByItemName(String modelName, String itemName) { Set toBeRemoved; try { lock.writeLock().lock(); - toBeRemoved = metadata.stream().filter(MetadataPredicates.ofItem(itemName)).collect(toSet()); - metadata.removeAll(toBeRemoved); + Set mdSet = metadata.getOrDefault(modelName, new HashSet<>()); + toBeRemoved = mdSet.stream().filter(MetadataPredicates.ofItem(itemName)).collect(toSet()); + mdSet.removeAll(toBeRemoved); + if (mdSet.isEmpty()) { + metadata.remove(modelName); + } } finally { lock.writeLock().unlock(); } @@ -106,7 +127,19 @@ public void removeMetadataByItemName(String itemName) { public Collection getAll() { try { lock.readLock().lock(); - return Set.copyOf(metadata); + Set set = metadata.keySet().stream().filter(name -> !ModelRepositoryImpl.isTemporaryModel(name)) + .map(name -> metadata.getOrDefault(name, Set.of())).flatMap(s -> s.stream()).collect(toSet()); + return Set.copyOf(set); + } finally { + lock.readLock().unlock(); + } + } + + public Collection getAllFromModel(String modelName) { + try { + lock.readLock().lock(); + Set set = metadata.getOrDefault(modelName, Set.of()); + return Set.copyOf(set); } finally { lock.readLock().unlock(); } diff --git a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/fileconverter/DslItemFileConverter.java b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/fileconverter/DslItemFileConverter.java index 35b974f5665..684d2751d52 100644 --- a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/fileconverter/DslItemFileConverter.java +++ b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/fileconverter/DslItemFileConverter.java @@ -12,6 +12,7 @@ */ package org.openhab.core.model.item.internal.fileconverter; +import java.io.ByteArrayInputStream; import java.io.OutputStream; import java.math.BigDecimal; import java.net.URI; @@ -34,9 +35,13 @@ import org.openhab.core.items.GroupItem; import org.openhab.core.items.Item; import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; import org.openhab.core.items.fileconverter.AbstractItemFileGenerator; import org.openhab.core.items.fileconverter.ItemFileGenerator; +import org.openhab.core.items.fileconverter.ItemFileParser; import org.openhab.core.model.core.ModelRepository; +import org.openhab.core.model.item.internal.GenericItemProvider; +import org.openhab.core.model.item.internal.GenericMetadataProvider; import org.openhab.core.model.items.ItemModel; import org.openhab.core.model.items.ItemsFactory; import org.openhab.core.model.items.ModelBinding; @@ -59,18 +64,24 @@ * @author Laurent Garnier - Initial contribution */ @NonNullByDefault -@Component(immediate = true, service = ItemFileGenerator.class) -public class DslItemFileConverter extends AbstractItemFileGenerator { +@Component(immediate = true, service = { ItemFileGenerator.class, ItemFileParser.class }) +public class DslItemFileConverter extends AbstractItemFileGenerator implements ItemFileParser { private final Logger logger = LoggerFactory.getLogger(DslItemFileConverter.class); private final ModelRepository modelRepository; + private final GenericItemProvider itemProvider; + private final GenericMetadataProvider metadataProvider; private final ConfigDescriptionRegistry configDescriptionRegistry; @Activate public DslItemFileConverter(final @Reference ModelRepository modelRepository, + final @Reference GenericItemProvider itemProvider, + final @Reference GenericMetadataProvider metadataProvider, final @Reference ConfigDescriptionRegistry configDescriptionRegistry) { this.modelRepository = modelRepository; + this.itemProvider = itemProvider; + this.metadataProvider = metadataProvider; this.configDescriptionRegistry = configDescriptionRegistry; } @@ -276,4 +287,37 @@ private List getConfigurationParameters(Metadata metadata, bool } return parameters; } + + @Override + public String getFileFormatParser() { + return "DSL"; + } + + @Override + public boolean parseFileFormat(String syntax, List items, List metadata, List errors, + List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + String modelName = modelRepository.createTemporaryModel("items", inputStream, errors, warnings); + if (modelName != null) { + itemProvider.processTemporaryModel(modelName); + items.addAll(itemProvider.getAllFromModel(modelName)); + metadata.addAll(metadataProvider.getAllFromModel(modelName)); + // TODO retrieve metadata + for (Item item : items) { + MetadataKey key; + Map config; + key = new MetadataKey("channel", item.getName()); + config = Map.of("myChannelParam1", BigDecimal.valueOf(10.25), "myChannelParam2", false, + "myChannelParam3", "My value"); + Metadata md = new Metadata(key, "myBinding:myTypoe:myThing#channel1", config); + metadata.add(md); + key = new MetadataKey("channel", item.getName()); + md = new Metadata(key, "myBinding:myTypoe:myThing#channel2", null); + metadata.add(md); + } + modelRepository.removeModel(modelName); + return true; + } + return false; + } } diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileParser.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileParser.java new file mode 100644 index 00000000000..72004a0b94d --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileParser.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.items.fileconverter; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.items.Item; +import org.openhab.core.items.Metadata; + +/** + * {@link ItemFileParser} is the interface to implement by any file parser for {@link Item} object. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public interface ItemFileParser { + + /** + * Returns the format of the syntax. + * + * @return the syntax format + */ + String getFileFormatParser(); + + /** + * Parse the provided syntax in file format and return the corresponding {@link Item} and {@link Metadata} objects + * without impacting the item and metadata registries. + * + * @param syntax the syntax in file format + * @param items the list of {@link Item} to fill + * @param metadata the list of {@link Metadata} to fill + * @param errors the list to be used to fill the errors + * @param warnings the list to be used to fill the warnings + * @return true if the parsing succeeded without errors + */ + boolean parseFileFormat(String syntax, List items, List metadata, List errors, + List warnings); +}