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 142f274b45d..4657e44a3e0 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,110 @@ public Response createFileFormatForThings(final @Context HttpHeaders httpHeaders } } ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - generator.generateFileFormat(outputStream, things, hideDefaultParameters); + generator.generateFileFormat(outputStream, 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 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(contentTypeHeader); + switch (contentTypeHeader) { + case "text/vnd.openhab.dsl.thing": + if (thingParser == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .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()) { + 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 '" + contentTypeHeader + "'!").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" */ @@ -465,6 +594,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); @@ -485,4 +622,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())) { + errors.add("Thing UID '" + thingUID + "' and bridge UID '" + bridgeUID + + "' are from different bindings"); + 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 728b8cc801a..7149a1591b0 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,7 +80,8 @@ 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) { if (things.isEmpty()) { return; } @@ -85,8 +91,8 @@ public synchronized void generateFileFormat(OutputStream out, List 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 @@ -100,8 +106,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); @@ -141,13 +147,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)); @@ -200,4 +206,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 9407c154c3b..d5e7f50074e 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,12 +88,14 @@ 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" ); private static final String UNWANTED_EXCEPTION_TEXT = "at [Source: UNKNOWN; byte offset: #UNKNOWN] "; + private static final String UNWANTED_EXCEPTION_TEXT2 = "\\n \\(through reference chain: .*"; private final Logger logger = LoggerFactory.getLogger(YamlModelRepositoryImpl.class); @@ -103,6 +107,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() // @@ -155,7 +161,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(); @@ -164,156 +169,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) { @@ -374,11 +387,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); }); } @@ -390,13 +403,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)); }); } } @@ -600,7 +613,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; @@ -641,6 +657,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 @@ -713,7 +755,8 @@ private Optional parseJsonNode(JsonNode node, Class List parseJsonMapNode(@Nullable JsonNode mapN } catch (JsonProcessingException e) { if (errors != null) { String msg = e.getMessage(); - errors.add("could not parse element %s to %s: %s".formatted(node.toPrettyString(), + errors.add("could not parse element with ID %s to %s: %s".formatted(id, elementClass.getSimpleName(), - msg == null ? "" : msg.replace(UNWANTED_EXCEPTION_TEXT, ""))); + msg == null ? "" + : msg.replace(UNWANTED_EXCEPTION_TEXT, "") + .replaceAll(UNWANTED_EXCEPTION_TEXT2, ""))); } } } 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 153c00391b6..abb29b8a2c2 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); + } }); }); } @@ -232,7 +246,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())); }); if (modelThings.isEmpty()) { 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); +}