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..5fac44664b5 --- /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/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 new file mode 100644 index 00000000000..f4f80675d48 --- /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,29 @@ +/* + * 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 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..1464843498e --- /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,132 @@ +/* + * 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; +import org.openhab.core.thing.link.ItemChannelLink; + +/** + * 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 + * @param channelLinks some items channel links + * @return file format item DTO object + */ + public static FileFormatItemDTO map(Item item, Collection metadata, + Collection channelLinks) { + ItemDTO itemDto = ItemDTOMapper.map(item); + FileFormatItemDTO dto = new FileFormatItemDTO(itemDto, itemDto instanceof GroupItemDTO); + + Map metadataDTO = new LinkedHashMap<>(); + metadata.forEach(md -> { + if (item.getName().equals(md.getUID().getItemName())) { + MetadataDTO mdDTO = new MetadataDTO(); + mdDTO.value = md.getValue(); + mdDTO.config = md.getConfiguration().isEmpty() ? null : md.getConfiguration(); + metadataDTO.put(md.getUID().getNamespace(), mdDTO); + } + }); + if (!metadataDTO.isEmpty()) { + dto.metadata = metadataDTO; + } + + List channelLinksDTO = new ArrayList<>(); + channelLinks.forEach(link -> { + if (item.getName().equals(link.getItemName())) { + channelLinksDTO.add(new FileFormatChannelLinkDTO(link.getLinkedUID().getAsString(), + link.getConfiguration().getProperties().isEmpty() ? null + : link.getConfiguration().getProperties())); + } + }); + if (!channelLinksDTO.isEmpty()) { + dto.channelLinks = channelLinksDTO; + } + + 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 ce91d748fda..d61a9a07c8f 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,17 +16,20 @@ import java.io.ByteArrayOutputStream; import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; 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,24 +53,40 @@ 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.ExtendedFileFormatDTO; +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.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; 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 +105,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 +118,7 @@ * * @author Laurent Garnier - Initial contribution * @author Laurent Garnier - Add YAML output for things + * @author Laurent Garnier - Add new API for conversion between file format and JSON */ @Component @JaxrsResource @@ -175,33 +196,45 @@ public class FileFormatResource implements RESTResource { param: my param value """; + private static final String GEN_ID_PATTERN = "gen_file_format_%d"; + private final Logger logger = LoggerFactory.getLogger(FileFormatResource.class); + private final ItemBuilderFactory itemBuilderFactory; private final ItemRegistry itemRegistry; private final MetadataRegistry metadataRegistry; private final ItemChannelLinkRegistry itemChannelLinkRegistry; 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 itemFileParsers = new ConcurrentHashMap<>(); private final Map thingFileGenerators = new ConcurrentHashMap<>(); + private final Map thingFileParsers = new ConcurrentHashMap<>(); + + private int counter; @Activate public FileFormatResource(// + final @Reference ItemBuilderFactory itemBuilderFactory, // final @Reference ItemRegistry itemRegistry, // final @Reference MetadataRegistry metadataRegistry, final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, final @Reference ThingRegistry thingRegistry, // final @Reference Inbox inbox, // final @Reference ThingTypeRegistry thingTypeRegistry, // + final @Reference ChannelTypeRegistry channelTypeRegistry, // final @Reference ConfigDescriptionRegistry configDescRegistry) { + this.itemBuilderFactory = itemBuilderFactory; this.itemRegistry = itemRegistry; this.metadataRegistry = metadataRegistry; this.itemChannelLinkRegistry = itemChannelLinkRegistry; this.thingRegistry = thingRegistry; this.inbox = inbox; this.thingTypeRegistry = thingTypeRegistry; + this.channelTypeRegistry = channelTypeRegistry; this.configDescRegistry = configDescRegistry; } @@ -218,6 +251,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); @@ -227,6 +269,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") @@ -264,7 +315,9 @@ public Response createFileFormatForItems(final @Context HttpHeaders httpHeaders, } } ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - generator.generateFileFormat(outputStream, items, getMetadata(items), hideDefaultParameters); + String genId = GEN_ID_PATTERN.formatted(++counter); + generator.setItemsToBeGenerated(genId, items, getMetadata(items), hideDefaultParameters); + generator.generateFileFormat(genId, outputStream); return Response.ok(new String(outputStream.toByteArray())).build(); } @@ -301,10 +354,192 @@ public Response createFileFormatForThings(final @Context HttpHeaders httpHeaders } } ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - generator.generateFileFormat(outputStream, things, hideDefaultParameters); + String genId = GEN_ID_PATTERN.formatted(++counter); + generator.setThingsToBeGenerated(genId, things, true, hideDefaultParameters); + generator.generateFileFormat(genId, outputStream); return Response.ok(new String(outputStream.toByteArray())).build(); } + @POST + @RolesAllowed({ Role.ADMIN }) + @Path("/create") + @Consumes({ MediaType.APPLICATION_JSON }) + @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, items, metadata, errors)) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } + + ThingFileGenerator thingGenerator = getThingFileGenerator(acceptHeader); + ItemFileGenerator itemGenerator = getItemFileGenerator(acceptHeader); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + String genId = GEN_ID_PATTERN.formatted(++counter); + 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(); + } + thingGenerator.setThingsToBeGenerated(genId, things, hideDefaultChannels, hideDefaultParameters); + thingGenerator.generateFileFormat(genId, outputStream); + 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(); + } + itemGenerator.setItemsToBeGenerated(genId, items, hideChannelLinksAndMetadata ? List.of() : metadata, + hideDefaultParameters); + itemGenerator.generateFileFormat(genId, outputStream); + break; + case "application/yaml": + if (thingGenerator != null) { + thingGenerator.setThingsToBeGenerated(genId, things, hideDefaultChannels, hideDefaultParameters); + } + if (itemGenerator != null) { + itemGenerator.setItemsToBeGenerated(genId, items, + hideChannelLinksAndMetadata ? List.of() : metadata, hideDefaultParameters); + } + if (thingGenerator != null) { + thingGenerator.generateFileFormat(genId, outputStream); + } else if (itemGenerator != null) { + itemGenerator.generateFileFormat(genId, outputStream); + } + break; + default: + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } + return Response.ok(new String(outputStream.toByteArray())).build(); + } + + @POST + @RolesAllowed({ Role.ADMIN }) + @Path("/parse") + @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 = { + @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 = "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 + Collection things = List.of(); + Collection items = List.of(); + Collection metadata = List.of(); + Collection channelLinks = List.of(); + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + ThingFileParser thingParser = getThingFileParser(contentTypeHeader); + ItemFileParser itemParser = getItemFileParser(contentTypeHeader); + String modelName = null; + String modelName2 = null; + 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(); + } + modelName = thingParser.startParsingFileFormat(input, errors, warnings); + if (modelName == null) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } + things = thingParser.getParsedThings(modelName); + if (things.isEmpty()) { + thingParser.finishParsingFileFormat(modelName); + 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(); + } + modelName2 = itemParser.startParsingFileFormat(input, errors, warnings); + if (modelName2 == null) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } + items = itemParser.getParsedItems(modelName2); + if (items.isEmpty()) { + itemParser.finishParsingFileFormat(modelName2); + return Response.status(Response.Status.BAD_REQUEST).entity("No item loaded from input").build(); + } + metadata = itemParser.getParsedMetadata(modelName2); + // We need to go through the thing parser to retrieve the items channel links + // But there is no need to parse again the input + if (thingParser != null) { + channelLinks = thingParser.getParsedChannelLinks(modelName2); + } + break; + case "application/yaml": + if (thingParser != null) { + modelName = thingParser.startParsingFileFormat(input, errors, warnings); + if (modelName == null) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } + things = thingParser.getParsedThings(modelName); + channelLinks = thingParser.getParsedChannelLinks(modelName); + } + if (itemParser != null) { + // Avoid parsing the input a second time + if (modelName == null) { + modelName2 = itemParser.startParsingFileFormat(input, errors, warnings); + if (modelName2 == null) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)) + .build(); + } + } + items = itemParser + .getParsedItems(modelName != null ? modelName : Objects.requireNonNull(modelName2)); + metadata = itemParser + .getParsedMetadata(modelName != null ? modelName : Objects.requireNonNull(modelName2)); + } + break; + default: + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported content type '" + contentTypeHeader + "'!").build(); + } + ExtendedFileFormatDTO result = convertToFileFormatDTO(things, items, metadata, channelLinks, warnings); + if (modelName != null && thingParser != null) { + thingParser.finishParsingFileFormat(modelName); + } + if (modelName2 != null && itemParser != null) { + itemParser.finishParsingFileFormat(modelName2); + } + return Response.ok(result).build(); + } + /* * Get all the metadata for a list of items including channel links mapped to metadata in the namespace "channel" */ @@ -465,6 +700,23 @@ 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"); + case "text/vnd.openhab.dsl.item" -> 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 +737,213 @@ private List getThingsOrDiscoveryResult(List thingUIDs) { return simulateThing(discoveryResult, thingType); }).toList(); } + + 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) { + 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); + } + } + 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(Collection things, Collection items, + Collection metadata, Collection channelLinks, 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)); + }); + } + if (!items.isEmpty()) { + dto.items = new ArrayList<>(); + items.forEach(item -> { + dto.items.add(FileFormatItemDTOMapper.map(item, metadata, channelLinks)); + }); + } + 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..f81a310933d 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; @@ -27,7 +28,8 @@ * come from. * * @author Kai Kreuzer - Initial contribution - * @author Laurent Garnier - Added method generateSyntaxFromModel + * @author Laurent Garnier - Added method generateFileFormat + * @author Laurent Garnier - Added methods createIsolatedModel and isIsolatedModel */ @NonNullByDefault public interface ModelRepository { @@ -96,11 +98,35 @@ public interface ModelRepository { void removeModelRepositoryChangeListener(ModelRepositoryChangeListener listener); /** - * Generate the syntax from a provided model content. + * Creates an isolated model in the repository + * + * An isolated model is a temporary model 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 createIsolatedModel(String modelType, InputStream inputStream, List errors, List warnings); + + /** + * Indicates if a model is an isolated model + * + * An isolated model is a temporary model loaded without impacting any object registry. + * + * @param modelName the model name + * @return true if the model identified by the provided name is an isolated model, false otherwise + */ + boolean isIsolatedModel(String modelName); + + /** + * Generate the DSL file format from a provided model type and model content. * * @param out the output stream to write the generated syntax to * @param modelType the model type * @param modelContent the content of the model */ - void generateSyntaxFromModel(OutputStream out, String modelType, EObject modelContent); + void generateFileFormat(OutputStream out, String modelType, EObject modelContent); } 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..629ce52a9cc 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; @@ -51,12 +50,16 @@ * @author Kai Kreuzer - Initial contribution * @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 generateFileFormat + * @author Laurent Garnier - Added methods createIsolatedModel and isIsolatedModel + return errors and warnings + * when loading a model */ @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 +90,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 +103,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 +167,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 +202,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_")) + && !isIsolatedModel(input.getURI().lastSegment())) .map(from -> from.getURI().path()).toList(); } } @@ -189,7 +215,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_")) { + && !isIsolatedModel(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 +237,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_")) { + && !isIsolatedModel(resource.getURI().lastSegment())) { logger.debug("Removing resource '{}'", resource.getURI().lastSegment()); ret.add(resource.getURI().lastSegment()); resourceSet.getResources().remove(resource); @@ -233,15 +259,27 @@ public void removeModelRepositoryChangeListener(ModelRepositoryChangeListener li } @Override - public void generateSyntaxFromModel(OutputStream out, String modelType, EObject modelContent) { + public @Nullable String createIsolatedModel(String modelType, InputStream inputStream, List errors, + List warnings) { + String name = "%sDSL_model_%d.%s".formatted(PREFIX_TMP_MODEL, ++counter, modelType); + return addOrRefreshModel(name, inputStream, errors, warnings) ? name : null; + } + + @Override + public boolean isIsolatedModel(String modelName) { + return modelName.startsWith(PREFIX_TMP_MODEL); + } + + @Override + public void generateFileFormat(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 +306,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 +337,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,7 +345,7 @@ public void generateSyntaxFromModel(OutputStream out, String modelType, EObject } finally { resourceSet.getResources().remove(resource); } - return null; + return true; } private void notifyListeners(String name, EventType type) { 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..959f51b8244 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 @@ -67,9 +67,11 @@ * * @author Kai Kreuzer - Initial contribution * @author Thomas Eichstaedt-Engelen - Initial contribution + * @author Laurent Garnier - Add method getAllFromModel + do not notify the item registry for isolated models */ @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 +172,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); @@ -177,7 +183,7 @@ private Collection getItemsFromModel(String modelName) { ItemModel model = (ItemModel) modelRepository.getModel(modelName); if (model != null) { for (ModelItem modelItem : model.getItems()) { - Item item = createItemFromModelItem(modelItem); + Item item = createItemFromModelItem(modelItem, modelName); if (item != null) { for (String groupName : modelItem.getGroups()) { ((GenericItem) item).addGroupName(groupName); @@ -206,8 +212,8 @@ 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()); - Item item = createItemFromModelItem(modelItem); + genericMetaDataProvider.removeMetadataByItemName(modelName, modelItem.getName()); + Item item = createItemFromModelItem(modelItem, modelName); if (item != null) { internalDispatchBindings(modelName, item, modelItem.getBindings()); } @@ -220,7 +226,7 @@ private void processBindingConfigsFromModel(String modelName, EventType type) { } } - private @Nullable Item createItemFromModelItem(ModelItem modelItem) { + private @Nullable Item createItemFromModelItem(ModelItem modelItem, String modelName) { Item item; if (modelItem instanceof ModelGroupItem modelGroupItem) { Item baseItem; @@ -253,10 +259,14 @@ private void processBindingConfigsFromModel(String modelName, EventType type) { String format = extractFormat(label); if (format != null) { label = label.substring(0, label.indexOf("[")).trim(); - stateDescriptionFragments.put(modelItem.getName(), - StateDescriptionFragmentBuilder.create().withPattern(format).build()); + if (!modelRepository.isIsolatedModel(modelName)) { + stateDescriptionFragments.put(modelItem.getName(), + StateDescriptionFragmentBuilder.create().withPattern(format).build()); + } } else { - stateDescriptionFragments.remove(modelItem.getName()); + if (!modelRepository.isIsolatedModel(modelName)) { + stateDescriptionFragments.remove(modelItem.getName()); + } } activeItem.setLabel(label); activeItem.setCategory(modelItem.getIcon()); @@ -303,7 +313,7 @@ private void dispatchBindingsPerItemType(String[] itemTypes) { for (String itemType : itemTypes) { String type = modelItem.getType(); if (type != null && itemType.equals(ItemUtil.getMainItemType(type))) { - Item item = createItemFromModelItem(modelItem); + Item item = createItemFromModelItem(modelItem, modelName); if (item != null) { internalDispatchBindings(null, modelName, item, modelItem.getBindings()); } @@ -324,7 +334,7 @@ private void dispatchBindingsPerType(BindingConfigReader reader, String[] bindin for (ModelBinding modelBinding : modelItem.getBindings()) { for (String bindingType : bindingTypes) { if (bindingType.equals(modelBinding.getType())) { - Item item = createItemFromModelItem(modelItem); + Item item = createItemFromModelItem(modelItem, modelName); if (item != null) { internalDispatchBindings(reader, modelName, item, modelItem.getBindings()); } @@ -389,7 +399,8 @@ private void internalDispatchBindings(@Nullable BindingConfigReader reader, Stri bindingType, item.getName(), e); } } else { - genericMetaDataProvider.addMetadata(bindingType, item.getName(), config, configuration.getProperties()); + genericMetaDataProvider.addMetadata(modelName, modelRepository.isIsolatedModel(modelName), bindingType, + item.getName(), config, configuration.getProperties()); } } } @@ -403,20 +414,22 @@ public void modelChanged(String modelName, EventType type) { Map oldItems = toItemMap(itemsMap.get(modelName)); Map newItems = toItemMap(getItemsFromModel(modelName)); itemsMap.put(modelName, newItems.values()); - for (Item newItem : newItems.values()) { - Item oldItem = oldItems.get(newItem.getName()); - if (oldItem != null) { - if (hasItemChanged(oldItem, newItem)) { - notifyListenersAboutUpdatedElement(oldItem, newItem); + if (!modelRepository.isIsolatedModel(modelName)) { + for (Item newItem : newItems.values()) { + Item oldItem = oldItems.get(newItem.getName()); + if (oldItem != null) { + if (hasItemChanged(oldItem, newItem)) { + notifyListenersAboutUpdatedElement(oldItem, newItem); + } + } else { + notifyListenersAboutAddedElement(newItem); } - } else { - notifyListenersAboutAddedElement(newItem); } } processBindingConfigsFromModel(modelName, type); for (Item oldItem : oldItems.values()) { if (!newItems.containsKey(oldItem.getName())) { - notifyAndCleanup(oldItem); + notifyAndCleanup(modelName, oldItem); } } break; @@ -425,17 +438,19 @@ 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) { - notifyListenersAboutRemovedElement(oldItem); - this.stateDescriptionFragments.remove(oldItem.getName()); - genericMetaDataProvider.removeMetadataByItemName(oldItem.getName()); + private void notifyAndCleanup(String modelName, Item oldItem) { + if (!modelRepository.isIsolatedModel(modelName)) { + notifyListenersAboutRemovedElement(oldItem); + this.stateDescriptionFragments.remove(oldItem.getName()); + } + genericMetaDataProvider.removeMetadataByItemName(modelName, oldItem.getName()); } protected boolean hasItemChanged(Item item1, Item item2) { @@ -523,6 +538,7 @@ private Map toItemMap(@Nullable Collection items) { @Override public @Nullable StateDescriptionFragment getStateDescriptionFragment(String itemName, @Nullable Locale locale) { + // FIXME: what to do for isolated models to not override data for items in item registry ? return stateDescriptionFragments.get(itemName); } } 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..9b0b6c02992 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; @@ -38,32 +40,42 @@ * methods. * * @author Kai Kreuzer - Initial contribution + * @author Laurent Garnier - Store metadata per model + do not notify the registry for isolated models */ @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); + private final Set isolatedModels = new HashSet<>(); /** * Adds metadata to this provider * + * @param modelName the model name + * @param isolated whether the model is an isolated model * @param bindingType * @param itemName * @param configuration */ - public void addMetadata(String bindingType, String itemName, String value, + public void addMetadata(String modelName, boolean isolated, 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); + if (isolated) { + isolatedModels.add(modelName); + } } finally { lock.writeLock().unlock(); } - notifyListenersAboutAddedElement(md); + if (!isolated) { + notifyListenersAboutAddedElement(md); + } } /** @@ -72,41 +84,79 @@ public void addMetadata(String bindingType, String itemName, String value, * @param namespace the namespace */ public void removeMetadataByNamespace(String namespace) { - Set toBeRemoved; + Map> toBeNotified; try { lock.writeLock().lock(); - toBeRemoved = metadata.stream().filter(MetadataPredicates.hasNamespace(namespace)).collect(toSet()); - metadata.removeAll(toBeRemoved); + toBeNotified = new HashMap<>(); + for (Map.Entry> entry : metadata.entrySet()) { + String modelName = entry.getKey(); + boolean notify = !isolatedModels.contains(modelName); + Set mdSet = entry.getValue(); + Set toBeRemoved = mdSet.stream().filter(MetadataPredicates.hasNamespace(namespace)) + .collect(toSet()); + mdSet.removeAll(toBeRemoved); + if (mdSet.isEmpty()) { + metadata.remove(modelName); + isolatedModels.remove(modelName); + } + if (notify && !toBeRemoved.isEmpty()) { + toBeNotified.put(modelName, toBeRemoved); + } + } } finally { lock.writeLock().unlock(); } - toBeRemoved.forEach(this::notifyListenersAboutRemovedElement); + toBeNotified.values().forEach((set) -> { + set.forEach(this::notifyListenersAboutRemovedElement); + }); } /** * Removes all meta-data for a given item * + * @param modelName the model name * @param itemName the item name */ - public void removeMetadataByItemName(String itemName) { - Set toBeRemoved; + public void removeMetadataByItemName(String modelName, String itemName) { + Set toBeNotified; try { lock.writeLock().lock(); - toBeRemoved = metadata.stream().filter(MetadataPredicates.ofItem(itemName)).collect(toSet()); - metadata.removeAll(toBeRemoved); + toBeNotified = new HashSet<>(); + boolean notify = !isolatedModels.contains(modelName); + Set mdSet = metadata.getOrDefault(modelName, new HashSet<>()); + Set toBeRemoved = mdSet.stream().filter(MetadataPredicates.ofItem(itemName)).collect(toSet()); + mdSet.removeAll(toBeRemoved); + if (mdSet.isEmpty()) { + metadata.remove(modelName); + isolatedModels.remove(modelName); + } + if (notify && !toBeRemoved.isEmpty()) { + toBeNotified.addAll(toBeRemoved); + } } finally { lock.writeLock().unlock(); } - for (Metadata m : toBeRemoved) { - notifyListenersAboutRemovedElement(m); - } + toBeNotified.forEach(this::notifyListenersAboutRemovedElement); } @Override public Collection getAll() { try { lock.readLock().lock(); - return Set.copyOf(metadata); + // Ignore isolated models + Set set = metadata.keySet().stream().filter(name -> !isolatedModels.contains(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 0d19cd5423b..48944fd7d2b 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; @@ -22,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -36,7 +38,10 @@ import org.openhab.core.items.Metadata; 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,26 @@ * @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 Map elementsToGenerate = new ConcurrentHashMap<>(); + 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; } @@ -80,7 +93,7 @@ public String getFileFormatGenerator() { } @Override - public synchronized void generateFileFormat(OutputStream out, List items, Collection metadata, + public void setItemsToBeGenerated(String id, List items, Collection metadata, boolean hideDefaultParameters) { if (items.isEmpty()) { return; @@ -90,7 +103,15 @@ public synchronized void generateFileFormat(OutputStream out, List items, model.getItems().add(buildModelItem(item, getChannelLinks(metadata, item.getName()), getMetadata(metadata, item.getName()), hideDefaultParameters)); } - modelRepository.generateSyntaxFromModel(out, "items", model); + elementsToGenerate.put(id, model); + } + + @Override + public void generateFileFormat(String id, OutputStream out) { + ItemModel model = elementsToGenerate.remove(id); + if (model != null) { + modelRepository.generateFileFormat(out, "items", model); + } } private ModelItem buildModelItem(Item item, List channelLinks, List metadata, @@ -279,4 +300,30 @@ private List getConfigurationParameters(Metadata metadata, bool } return parameters; } + + @Override + public String getFileFormatParser() { + return "DSL"; + } + + @Override + public @Nullable String startParsingFileFormat(String syntax, List errors, List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + return modelRepository.createIsolatedModel("items", inputStream, errors, warnings); + } + + @Override + public Collection getParsedItems(String modelName) { + return itemProvider.getAllFromModel(modelName); + } + + @Override + public Collection getParsedMetadata(String modelName) { + return metadataProvider.getAllFromModel(modelName); + } + + @Override + public void finishParsingFileFormat(String modelName) { + modelRepository.removeModel(modelName); + } } diff --git a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/GenericItemChannelLinkProvider.java b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/GenericItemChannelLinkProvider.java index f14cd9def26..75a0f3016f2 100644 --- a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/GenericItemChannelLinkProvider.java +++ b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/GenericItemChannelLinkProvider.java @@ -40,15 +40,17 @@ * * @author Oliver Libutzki - Initial contribution * @author Alex Tugarev - Added parsing of multiple Channel UIDs + * @author Laurent Garnier - Store channel links per context (model) + do not notify the registry for isolated models */ @NonNullByDefault -@Component(immediate = true, service = { ItemChannelLinkProvider.class, BindingConfigReader.class }) +@Component(immediate = true, service = { GenericItemChannelLinkProvider.class, ItemChannelLinkProvider.class, + BindingConfigReader.class }) public class GenericItemChannelLinkProvider extends AbstractProvider implements BindingConfigReader, ItemChannelLinkProvider { private final Logger logger = LoggerFactory.getLogger(GenericItemChannelLinkProvider.class); - /** caches binding configurations. maps itemNames to {@link ItemChannelLink}s */ - protected Map> itemChannelLinkMap = new ConcurrentHashMap<>(); + /** caches binding configurations. maps context to a map mapping itemNames to {@link ItemChannelLink}s */ + protected Map>> itemChannelLinkMap = new ConcurrentHashMap<>(); private Map> addedItemChannels = new ConcurrentHashMap<>(); @@ -110,17 +112,21 @@ private void createItemChannelLink(String context, String itemName, String chann previousItemNames.remove(itemName); } + Map> channelLinkMap = Objects + .requireNonNull(itemChannelLinkMap.computeIfAbsent(context, k -> new ConcurrentHashMap<>())); // Create a HashMap with an initial capacity of 2 (the default is 16) to save memory because most items have // only one channel. A capacity of 2 is enough to avoid resizing the HashMap in most cases, whereas 1 would // trigger a resize as soon as one element is added. Map links = Objects - .requireNonNull(itemChannelLinkMap.computeIfAbsent(itemName, k -> new HashMap<>(2))); + .requireNonNull(channelLinkMap.computeIfAbsent(itemName, k -> new HashMap<>(2))); ItemChannelLink oldLink = links.put(channelUIDObject, itemChannelLink); - if (oldLink == null) { - notifyListenersAboutAddedElement(itemChannelLink); - } else { - notifyListenersAboutUpdatedElement(oldLink, itemChannelLink); + if (isValidContextForListeners(context)) { + if (oldLink == null) { + notifyListenersAboutAddedElement(itemChannelLink); + } else { + notifyListenersAboutUpdatedElement(oldLink, itemChannelLink); + } } addedItemChannels.computeIfAbsent(itemName, k -> new HashSet<>(2)).add(channelUIDObject); } @@ -141,29 +147,48 @@ public void stopConfigurationUpdate(String context) { if (previousItemNames == null) { return; } + Map> channelLinkMap = (itemChannelLinkMap.getOrDefault(context, + new HashMap<>())); for (String itemName : previousItemNames) { // we remove all binding configurations that were not processed - Map links = itemChannelLinkMap.remove(itemName); - if (links != null) { + Map links = channelLinkMap.remove(itemName); + if (links != null && isValidContextForListeners(context)) { links.values().forEach(this::notifyListenersAboutRemovedElement); } } Optional.ofNullable(contextMap.get(context)).ifPresent(ctx -> ctx.removeAll(previousItemNames)); addedItemChannels.forEach((itemName, addedChannelUIDs) -> { - Map links = itemChannelLinkMap.getOrDefault(itemName, Map.of()); + Map links = channelLinkMap.getOrDefault(itemName, Map.of()); Set removedChannelUIDs = new HashSet<>(links.keySet()); removedChannelUIDs.removeAll(addedChannelUIDs); removedChannelUIDs.forEach(removedChannelUID -> { ItemChannelLink link = links.remove(removedChannelUID); - notifyListenersAboutRemovedElement(link); + if (link != null && isValidContextForListeners(context)) { + notifyListenersAboutRemovedElement(link); + } }); }); addedItemChannels.clear(); + if (channelLinkMap.isEmpty()) { + itemChannelLinkMap.remove(context); + } } @Override public Collection getAll() { - return itemChannelLinkMap.values().stream().flatMap(m -> m.values().stream()).toList(); + return itemChannelLinkMap.keySet().stream().filter(context -> isValidContextForListeners(context)) + .map(name -> itemChannelLinkMap.getOrDefault(name, Map.of())).flatMap(m -> m.values().stream()) + .flatMap(m -> m.values().stream()).toList(); + } + + public Collection getAllFromContext(String context) { + return itemChannelLinkMap.getOrDefault(context, Map.of()).values().stream().flatMap(m -> m.values().stream()) + .toList(); + } + + private boolean isValidContextForListeners(String context) { + // Ignore isolated models + return !context.startsWith("tmp_"); } } 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..84531b4b595 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 getAllFromModel + do not notify the thing registry for isolated models */ -@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"; @@ -108,7 +109,18 @@ class GenericThingProvider extends AbstractProviderLazyNullness implement } override Collection getAll() { - thingsMap.values.flatten.toList + val things = new ArrayList + thingsMap.keySet.filter[!modelRepository.isIsolatedModel(it)].forEach([ + val things2 = thingsMap.get(it) + if (things2 !== null) { + things.addAll(things2) + } + ]) + return things + } + + def public Collection getAllFromModel(String modelName) { + thingsMap.getOrDefault(modelName, List.of()) } def private void createThingsFromModel(String modelName) { @@ -470,7 +482,9 @@ class GenericThingProvider extends AbstractProviderLazyNullness implement val removedThings = oldThings.filter[!newThingUIDs.contains(it.UID)] removedThings.forEach [ logger.debug("Removing thing '{}' from model '{}'.", it.UID, modelName) - notifyListenersAboutRemovedElement + if (!modelRepository.isIsolatedModel(modelName)) { + notifyListenersAboutRemovedElement + } ] createThingsFromModel(modelName) thingsMap.get(modelName).removeAll(removedThings) @@ -479,9 +493,11 @@ class GenericThingProvider extends AbstractProviderLazyNullness implement case org.openhab.core.model.core.EventType.REMOVED: { logger.debug("Removing all things from model '{}'.", modelName) val things = thingsMap.remove(modelName) ?: newArrayList - things.forEach [ - notifyListenersAboutRemovedElement - ] + if (!modelRepository.isIsolatedModel(modelName)) { + things.forEach [ + notifyListenersAboutRemovedElement + ] + } } } } @@ -621,12 +637,16 @@ class GenericThingProvider extends AbstractProviderLazyNullness implement things.remove(oldThing) things.add(newThing) logger.debug("Updating thing '{}' from model '{}'.", newThing.UID, modelName); - notifyListenersAboutUpdatedElement(oldThing, newThing) + if (!modelRepository.isIsolatedModel(modelName)) { + notifyListenersAboutUpdatedElement(oldThing, newThing) + } } } else { things.add(newThing) logger.debug("Adding thing '{}' from model '{}'.", newThing.UID, modelName); - newThing.notifyListenersAboutAddedElement + if (!modelRepository.isIsolatedModel(modelName)) { + newThing.notifyListenersAboutAddedElement + } } ] } @@ -658,7 +678,7 @@ class GenericThingProvider extends AbstractProviderLazyNullness implement thingsMap.get(modelName).remove(oldThing) thingsMap.get(modelName).add(newThing) logger.debug("Refreshing thing '{}' after successful retry", newThing.UID) - if (!ThingHelper.equals(oldThing, newThing)) { + if (!ThingHelper.equals(oldThing, newThing) && !modelRepository.isIsolatedModel(modelName)) { notifyListenersAboutUpdatedElement(oldThing, newThing) } } else { 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..799bc285cc9 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,18 +12,24 @@ */ package org.openhab.core.model.thing.internal.fileconverter; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.math.BigDecimal; +import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.eclipse.jdt.annotation.NonNullByDefault; 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.GenericItemChannelLinkProvider; +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 +42,8 @@ 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.link.ItemChannelLink; import org.openhab.core.thing.type.ChannelKind; import org.openhab.core.thing.type.ChannelTypeRegistry; import org.openhab.core.thing.type.ChannelTypeUID; @@ -53,20 +61,28 @@ * @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; + private final GenericItemChannelLinkProvider itemChannelLinkProvider; + + private final Map elementsToGenerate = new ConcurrentHashMap<>(); @Activate public DslThingFileConverter(final @Reference ModelRepository modelRepository, + final @Reference GenericThingProvider thingProvider, + final @Reference GenericItemChannelLinkProvider itemChannelLinkProvider, final @Reference ThingTypeRegistry thingTypeRegistry, final @Reference ChannelTypeRegistry channelTypeRegistry, final @Reference ConfigDescriptionRegistry configDescRegistry) { super(thingTypeRegistry, channelTypeRegistry, configDescRegistry); this.modelRepository = modelRepository; + this.thingProvider = thingProvider; + this.itemChannelLinkProvider = itemChannelLinkProvider; } @Override @@ -75,7 +91,8 @@ public String getFileFormatGenerator() { } @Override - public synchronized void generateFileFormat(OutputStream out, List things, boolean hideDefaultParameters) { + public void setThingsToBeGenerated(String id, List things, boolean hideDefaultChannels, + boolean hideDefaultParameters) { if (things.isEmpty()) { return; } @@ -85,23 +102,32 @@ 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 - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - modelRepository.generateSyntaxFromModel(outputStream, "things", model); - String syntax = new String(outputStream.toByteArray()).replaceAll(":\"([a-zA-Z0-9_][a-zA-Z0-9_-]*)\"", ":$1"); - try { - out.write(syntax.getBytes()); - } catch (IOException e) { - logger.warn("Exception when writing the generated syntax {}", e.getMessage()); + elementsToGenerate.put(id, model); + } + + @Override + public void generateFileFormat(String id, OutputStream out) { + ThingModel model = elementsToGenerate.remove(id); + if (model != null) { + // 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 + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + modelRepository.generateFileFormat(outputStream, "things", model); + String syntax = new String(outputStream.toByteArray()).replaceAll(":\"([a-zA-Z0-9_][a-zA-Z0-9_-]*)\"", + ":$1"); + try { + out.write(syntax.getBytes()); + } catch (IOException e) { + logger.warn("Exception when writing the generated syntax {}", e.getMessage()); + } } } - 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 +167,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 +226,30 @@ private ModelChannel buildModelChannel(Channel channel, boolean hideDefaultParam } return property; } + + @Override + public String getFileFormatParser() { + return "DSL"; + } + + @Override + public @Nullable String startParsingFileFormat(String syntax, List errors, List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + return modelRepository.createIsolatedModel("things", inputStream, errors, warnings); + } + + @Override + public Collection getParsedThings(String modelName) { + return thingProvider.getAllFromModel(modelName); + } + + @Override + public Collection getParsedChannelLinks(String modelName) { + return itemChannelLinkProvider.getAllFromContext(modelName); + } + + @Override + public void finishParsingFileFormat(String modelName) { + modelRepository.removeModel(modelName); + } } 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 eaa33edbc30..bcff57d92f9 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 method generateSyntaxFromElements + * @author Laurent Garnier - Added methods addElementsToBeGenerated, generateFileFormat, createTemporaryModel and + * removeTemporaryModel */ @NonNullByDefault public interface YamlModelRepository { @@ -32,10 +35,38 @@ public interface YamlModelRepository { void updateElementInModel(String modelName, YamlElement element); /** - * Generate the YAML syntax from a provided list of elements. + * Associate a list of elements to be generated to an identifier. * - * @param out the output stream to write the generated syntax to - * @param elements the list of elements to includ + * @param id the identifier of the file format generation + * @param elements the elements to be added */ - void generateSyntaxFromElements(OutputStream out, List elements); + void addElementsToBeGenerated(String id, List elements); + + /** + * Generate the YAML file format for all elements that were associated to the provided identifier. + * + * @param id the identifier of the file format generation + * @param out the output stream to write to + */ + void generateFileFormat(String id, OutputStream out); + + /** + * Creates an isolated model in the repository + * + * An isolated model is a temporary model 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 createIsolatedModel(InputStream inputStream, List errors, List warnings); + + /** + * Removes an isolated model from the repository + * + * @param modelName the name of the model to be removed + */ + void removeIsolatedModel(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 f1fa56660c6..9d92109bd8f 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; @@ -76,8 +77,9 @@ * @author Jan N. Klug - Refactored for multiple types per file and add modifying possibility * @author Laurent Garnier - Map used instead of table * @author Laurent Garnier - Added basic version management - * @author Laurent Garnier - Added method generateSyntaxFromElements + new parameters - * for method isValid + * @author Laurent Garnier - new parameters to retrieve errors and warnings when loading a file + * @author Laurent Garnier - Added methods addElementsToBeGenerated, generateFileFormat, createTemporaryModel and + * removeTemporaryModel */ @NonNullByDefault @Component(immediate = true) @@ -85,6 +87,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener, private static final int DEFAULT_MODEL_VERSION = 1; 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" @@ -92,6 +95,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener, ); 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,10 @@ 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 final Map> elementsToGenerate = new ConcurrentHashMap<>(); + + private int counter; + @Activate public YamlModelRepositoryImpl(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) { YAMLFactory yamlFactory = YAMLFactory.builder() // @@ -154,7 +162,6 @@ public void deactivate() { // The method is "synchronized" to avoid concurrent files processing @Override - @SuppressWarnings({ "rawtypes", "unchecked" }) public synchronized void processWatchEvent(Kind kind, Path fullPath) { Path relativePath = watchPath.relativize(fullPath); String modelName = relativePath.toString(); @@ -163,136 +170,145 @@ public synchronized void processWatchEvent(Kind kind, Path fullPath) { 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) { + 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); + }); + } + + @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); + + 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; + } + + newElementNames.add(elementName); + JsonNode node = element.getValue(); + + 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); + if (getElementName(YamlSemanticTagDTO.class).equals(elementName) && node.isArray()) { logger.warn( - "Model {} has version {}, but only versions between 1 and {} are supported. Ignoring it.", - modelName, modelVersion, DEFAULT_MODEL_VERSION); - removeModel(modelName); - return; + "Your YAML model {} contains custom tags with an old and now unsupported syntax. An upgrade of this model is required to upgrade to the new syntax. This can be done by running the upgrade tool.", + modelName); } - if (kind == Kind.CREATE) { - logger.info("Adding YAML model {}", modelName); - } else { - logger.info("Updating YAML model {}", modelName); + continue; + } + JsonNode newNodeElements = node; + + 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( + parseJsonMapNode(oldNodeElements, elementClass, null, null)); + Map newElements = listToMap( + parseJsonMapNode(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)); } - 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; - } - newElementNames.add(elementName); - JsonNode node = element.getValue(); - - 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); - if (getElementName(YamlSemanticTagDTO.class).equals(elementName) && node.isArray()) { - logger.warn( - "Your YAML model {} contains custom tags with an old and now unsupported syntax. An upgrade of this model is required to upgrade to the new syntax. This can be done by running the upgrade tool.", - modelName); - } - continue; - } - JsonNode newNodeElements = node; - - 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( - parseJsonMapNode(oldNodeElements, elementClass, null, null)); - Map newElements = listToMap( - parseJsonMapNode(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); - } + if (!addedElements.isEmpty()) { + elementListener.addedModel(modelName, addedElements); + } + if (!removedElements.isEmpty()) { + elementListener.removedModel(modelName, removedElements); + } + if (!updatedElements.isEmpty()) { + elementListener.updatedModel(modelName, updatedElements); + } + } - if (!addedElements.isEmpty()) { - elementListener.addedModel(modelName, addedElements); - } - if (!removedElements.isEmpty()) { - elementListener.removedModel(modelName, removedElements); - } - if (!updatedElements.isEmpty()) { - elementListener.updatedModel(modelName, updatedElements); - } - } + // replace cache + model.getNodes().put(elementName, newNodeElements); + } - // replace cache - model.getNodes().put(elementName, newNodeElements); - } + // remove removed elements + model.getNodes().entrySet().removeIf(e -> { + String elementName = e.getKey(); + if (newElementNames.contains(elementName)) { + return false; + } - // remove removed elements - model.getNodes().entrySet().removeIf(e -> { - String elementName = e.getKey(); - if (newElementNames.contains(elementName)) { - return false; - } + JsonNode removedNode = e.getValue(); + getElementListeners(elementName, modelVersion).forEach(listener -> { + List removedElements = parseJsonMapNode(removedNode, listener.getElementClass(), null, null); + listener.removedModel(modelName, removedElements); + }); + return true; + }); - JsonNode removedNode = e.getValue(); - getElementListeners(elementName, modelVersion).forEach(listener -> { - List removedElements = parseJsonMapNode(removedNode, listener.getElementClass(), null, null); - listener.removedModel(modelName, removedElements); - }); - return true; - }); + checkElementNames(modelName, model, warnings); - checkElementNames(modelName, model); - } else { - logger.trace("Ignored {}", fullPath); - } - } catch (IOException e) { - logger.warn("Failed to process model {}: {}", modelName, e.getMessage()); - } + return valid; } @SuppressWarnings({ "rawtypes", "unchecked" }) @@ -344,11 +360,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); }); } @@ -360,12 +376,12 @@ 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.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)); }); } } @@ -509,7 +525,10 @@ private void writeModel(String modelName) { logger.warn("Failed to write model {} to disk because it is not known.", modelName); return; } - + if (isIsolatedModel(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; @@ -543,7 +562,40 @@ private void writeModel(String modelName) { } @Override - public void generateSyntaxFromElements(OutputStream out, List elements) { + public synchronized @Nullable String createIsolatedModel(InputStream inputStream, List errors, + List warnings) { + String modelName = "%sYAML_model_%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 removeIsolatedModel(String modelName) { + if (isIsolatedModel(modelName)) { + removeModel(modelName); + } + } + + public static boolean isIsolatedModel(String modelName) { + return modelName.startsWith(PREFIX_TMP_MODEL); + } + + @Override + public void addElementsToBeGenerated(String id, List elements) { + List elts = Objects.requireNonNull(elementsToGenerate.computeIfAbsent(id, k -> new ArrayList<>())); + elts.addAll(elements); + } + + @Override + public void generateFileFormat(String id, OutputStream out) { + List elements = elementsToGenerate.remove(id); // create the model JsonNodeFactory nodeFactory = objectMapper.getNodeFactory(); ObjectNode rootNode = nodeFactory.objectNode(); @@ -552,15 +604,17 @@ public void generateSyntaxFromElements(OutputStream out, List eleme // First separate elements per type Map> elementsPerTypes = new HashMap<>(); - elements.forEach(element -> { - String elementName = getElementName(element.getClass()); - List elts = elementsPerTypes.get(elementName); - if (elts == null) { - elts = new ArrayList<>(); - elementsPerTypes.put(elementName, elts); - } - elts.add(element); - }); + if (elements != null) { + elements.forEach(element -> { + String elementName = getElementName(element.getClass()); + List elts = elementsPerTypes.get(elementName); + if (elts == null) { + elts = new ArrayList<>(); + elementsPerTypes.put(elementName, elts); + } + elts.add(element); + }); + } // Generate one entry for each element type elementsPerTypes.entrySet().forEach(entry -> { Map mapElts = new LinkedHashMap<>(); @@ -616,9 +670,11 @@ private 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/items/YamlChannelLinkProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlChannelLinkProvider.java index 9d7267cafef..ccb66b7c591 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlChannelLinkProvider.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlChannelLinkProvider.java @@ -23,6 +23,7 @@ import org.openhab.core.common.registry.AbstractProvider; import org.openhab.core.config.core.Configuration; import org.openhab.core.items.ItemProvider; +import org.openhab.core.model.yaml.internal.YamlModelRepositoryImpl; import org.openhab.core.model.yaml.internal.util.YamlElementUtils; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.link.ItemChannelLink; @@ -41,7 +42,8 @@ * @author Laurent Garnier - Initial contribution */ @NonNullByDefault -@Component(immediate = true, service = { ItemChannelLinkProvider.class, YamlChannelLinkProvider.class }) +@Component(immediate = true, service = { YamlChannelLinkProvider.class, ItemChannelLinkProvider.class, + YamlChannelLinkProvider.class }) public class YamlChannelLinkProvider extends AbstractProvider implements ItemChannelLinkProvider { private final Logger logger = LoggerFactory.getLogger(YamlChannelLinkProvider.class); @@ -51,7 +53,9 @@ public class YamlChannelLinkProvider extends AbstractProvider i @Override public Collection getAll() { - return itemsChannelLinksMap.values().stream().flatMap(m -> m.values().stream()) + // Ignore isolated models + return itemsChannelLinksMap.keySet().stream().filter(name -> !YamlModelRepositoryImpl.isIsolatedModel(name)) + .map(name -> itemsChannelLinksMap.getOrDefault(name, Map.of())).flatMap(m -> m.values().stream()) .flatMap(m -> m.values().stream()).toList(); } @@ -99,21 +103,27 @@ public void updateItemChannelLinks(String modelName, String itemName, Map { ItemChannelLink link = links.remove(uid); if (link != null) { - logger.debug("notify removed item channel link {}", link.getUID()); - notifyListenersAboutRemovedElement(link); + logger.debug("model {} removed channel link {}", modelName, link.getUID()); + if (!YamlModelRepositoryImpl.isIsolatedModel(modelName)) { + notifyListenersAboutRemovedElement(link); + } } }); if (links.isEmpty()) { diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemProvider.java index b6ee758120f..e646269f7b5 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemProvider.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemProvider.java @@ -33,6 +33,7 @@ import org.openhab.core.items.dto.ItemDTOMapper; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.model.yaml.YamlModelListener; +import org.openhab.core.model.yaml.internal.YamlModelRepositoryImpl; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Deactivate; @@ -75,7 +76,9 @@ public void deactivate() { @Override public Collection getAll() { - return itemsMap.values().stream().flatMap(list -> list.stream()).toList(); + // Ignore isolated models + return itemsMap.keySet().stream().filter(name -> !YamlModelRepositoryImpl.isIsolatedModel(name)) + .map(name -> itemsMap.getOrDefault(name, List.of())).flatMap(list -> list.stream()).toList(); } public Collection getAllFromModel(String modelName) { @@ -114,7 +117,9 @@ public void addedModel(String modelName, Collection elements) { added.forEach((item, itemDTO) -> { String name = item.getName(); logger.debug("model {} added item {}", modelName, name); - notifyListenersAboutAddedElement(item); + if (!YamlModelRepositoryImpl.isIsolatedModel(modelName)) { + notifyListenersAboutAddedElement(item); + } processChannelLinks(modelName, name, itemDTO); processMetadata(modelName, name, itemDTO); }); @@ -138,11 +143,15 @@ public void updatedModel(String modelName, Collection elements) { modelItems.remove(oldItem); modelItems.add(item); logger.debug("model {} updated item {}", modelName, name); - notifyListenersAboutUpdatedElement(oldItem, item); + if (!YamlModelRepositoryImpl.isIsolatedModel(modelName)) { + notifyListenersAboutUpdatedElement(oldItem, item); + } }, () -> { modelItems.add(item); logger.debug("model {} added item {}", modelName, name); - notifyListenersAboutAddedElement(item); + if (!YamlModelRepositoryImpl.isIsolatedModel(modelName)) { + notifyListenersAboutAddedElement(item); + } }); processChannelLinks(modelName, name, itemDTO); processMetadata(modelName, name, itemDTO); @@ -159,7 +168,9 @@ public void removedModel(String modelName, Collection elements) { modelItems.stream().filter(i -> i.getName().equals(name)).findFirst().ifPresentOrElse(oldItem -> { modelItems.remove(oldItem); logger.debug("model {} removed item {}", modelName, name); - notifyListenersAboutRemovedElement(oldItem); + if (!YamlModelRepositoryImpl.isIsolatedModel(modelName)) { + notifyListenersAboutRemovedElement(oldItem); + } }, () -> logger.debug("model {} item {} not found", modelName, name)); processChannelLinks(modelName, name, null); processMetadata(modelName, name, null); diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataProvider.java index 5c53112f85f..e9e8968297b 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataProvider.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataProvider.java @@ -25,6 +25,7 @@ import org.openhab.core.items.Metadata; import org.openhab.core.items.MetadataKey; import org.openhab.core.items.MetadataProvider; +import org.openhab.core.model.yaml.internal.YamlModelRepositoryImpl; import org.openhab.core.model.yaml.internal.util.YamlElementUtils; import org.osgi.service.component.annotations.Component; import org.slf4j.Logger; @@ -49,8 +50,10 @@ public class YamlMetadataProvider extends AbstractProvider implements @Override public Collection getAll() { - return metadataMap.values().stream().flatMap(m -> m.values().stream()).flatMap(m -> m.values().stream()) - .toList(); + // Ignore isolated models + return metadataMap.keySet().stream().filter(name -> !YamlModelRepositoryImpl.isIsolatedModel(name)) + .map(name -> metadataMap.getOrDefault(name, Map.of())).flatMap(m -> m.values().stream()) + .flatMap(m -> m.values().stream()).toList(); } public Collection getAllFromModel(String modelName) { @@ -76,21 +79,27 @@ public void updateMetadata(String modelName, String itemName, Map { Metadata md = namespacesMetadataMap.remove(namespace); if (md != null) { - logger.debug("notify removed metadata {}", md.getUID()); - notifyListenersAboutRemovedElement(md); + logger.debug("model {} removed metadata {}", modelName, namespace); + if (!YamlModelRepositoryImpl.isIsolatedModel(modelName)) { + notifyListenersAboutRemovedElement(md); + } } }); if (namespacesMetadataMap.isEmpty()) { diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverter.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverter.java index 925f8f6a6d6..fb17585b523 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverter.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverter.java @@ -12,6 +12,7 @@ */ package org.openhab.core.model.yaml.internal.items.fileconverter; +import java.io.ByteArrayInputStream; import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; @@ -26,6 +27,7 @@ import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.config.core.ConfigDescription; import org.openhab.core.config.core.ConfigDescriptionParameter; import org.openhab.core.config.core.ConfigDescriptionRegistry; @@ -38,12 +40,16 @@ import org.openhab.core.items.Metadata; 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.library.CoreItemFactory; import org.openhab.core.model.yaml.YamlElement; import org.openhab.core.model.yaml.YamlModelRepository; +import org.openhab.core.model.yaml.internal.items.YamlChannelLinkProvider; import org.openhab.core.model.yaml.internal.items.YamlGroupDTO; import org.openhab.core.model.yaml.internal.items.YamlItemDTO; +import org.openhab.core.model.yaml.internal.items.YamlItemProvider; import org.openhab.core.model.yaml.internal.items.YamlMetadataDTO; +import org.openhab.core.model.yaml.internal.items.YamlMetadataProvider; import org.openhab.core.types.State; import org.openhab.core.types.StateDescription; import org.osgi.service.component.annotations.Activate; @@ -56,17 +62,25 @@ * @author Laurent Garnier - Initial contribution */ @NonNullByDefault -@Component(immediate = true, service = ItemFileGenerator.class) -public class YamlItemFileConverter extends AbstractItemFileGenerator { +@Component(immediate = true, service = { ItemFileGenerator.class, ItemFileParser.class }) +public class YamlItemFileConverter extends AbstractItemFileGenerator implements ItemFileParser { private final YamlModelRepository modelRepository; + private final YamlItemProvider itemProvider; + private final YamlMetadataProvider metadataProvider; + private final YamlChannelLinkProvider channelLinkProvider; private final ConfigDescriptionRegistry configDescriptionRegistry; @Activate public YamlItemFileConverter(final @Reference YamlModelRepository modelRepository, + final @Reference YamlItemProvider itemProvider, final @Reference YamlMetadataProvider metadataProvider, + final @Reference YamlChannelLinkProvider channelLinkProvider, final @Reference ConfigDescriptionRegistry configDescRegistry) { super(); this.modelRepository = modelRepository; + this.itemProvider = itemProvider; + this.metadataProvider = metadataProvider; + this.channelLinkProvider = channelLinkProvider; this.configDescriptionRegistry = configDescRegistry; } @@ -76,14 +90,19 @@ public String getFileFormatGenerator() { } @Override - public void generateFileFormat(OutputStream out, List items, Collection metadata, + public void setItemsToBeGenerated(String id, List items, Collection metadata, boolean hideDefaultParameters) { List elements = new ArrayList<>(); items.forEach(item -> { elements.add(buildItemDTO(item, getChannelLinks(metadata, item.getName()), getMetadata(metadata, item.getName()), hideDefaultParameters)); }); - modelRepository.generateSyntaxFromElements(out, elements); + modelRepository.addElementsToBeGenerated(id, elements); + } + + @Override + public void generateFileFormat(String id, OutputStream out) { + modelRepository.generateFileFormat(id, out); } private YamlItemDTO buildItemDTO(Item item, List channelLinks, List metadata, @@ -250,4 +269,30 @@ private List getConfigurationParameters(Metadata metadata, bool } return parameters; } + + @Override + public String getFileFormatParser() { + return "YAML"; + } + + @Override + public @Nullable String startParsingFileFormat(String syntax, List errors, List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + return modelRepository.createIsolatedModel(inputStream, errors, warnings); + } + + @Override + public Collection getParsedItems(String modelName) { + return itemProvider.getAllFromModel(modelName); + } + + @Override + public Collection getParsedMetadata(String modelName) { + return metadataProvider.getAllFromModel(modelName); + } + + @Override + public void finishParsingFileFormat(String modelName) { + modelRepository.removeIsolatedModel(modelName); + } } 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 efc43e9cb4c..57cdb42ca55 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 @@ -33,6 +33,7 @@ import org.openhab.core.config.core.Configuration; import org.openhab.core.i18n.LocaleProvider; import org.openhab.core.model.yaml.YamlModelListener; +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; @@ -147,7 +148,13 @@ public void deactivate() { @Override public Collection getAll() { - return thingsMap.values().stream().flatMap(list -> list.stream()).toList(); + // Ignore isolated models + return thingsMap.keySet().stream().filter(name -> !YamlModelRepositoryImpl.isIsolatedModel(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 @@ -173,7 +180,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.isIsolatedModel(modelName)) { + notifyListenersAboutAddedElement(t); + } }); } @@ -187,11 +196,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.isIsolatedModel(modelName)) { + notifyListenersAboutUpdatedElement(oldThing, t); + } }, () -> { modelThings.add(t); logger.debug("model {} added thing {}", modelName, t.getUID()); - notifyListenersAboutAddedElement(t); + if (!YamlModelRepositoryImpl.isIsolatedModel(modelName)) { + notifyListenersAboutAddedElement(t); + } }); }); } @@ -204,7 +217,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.isIsolatedModel(modelName)) { + notifyListenersAboutRemovedElement(oldThing); + } }, () -> logger.debug("model {} thing {} not found", modelName, t.getUID())); }); if (modelThings.isEmpty()) { @@ -293,7 +308,8 @@ private boolean retryCreateThing(ThingHandlerFactory handlerFactory, ThingTypeUI if (newThing != null) { logger.debug("Successfully loaded thing \'{}\' during retry", thingUID); Thing oldThing = null; - for (Collection modelThings : thingsMap.values()) { + for (Map.Entry> entry : thingsMap.entrySet()) { + Collection modelThings = entry.getValue(); oldThing = modelThings.stream().filter(t -> t.getUID().equals(newThing.getUID())).findFirst() .orElse(null); if (oldThing != null) { @@ -301,7 +317,8 @@ private boolean retryCreateThing(ThingHandlerFactory handlerFactory, ThingTypeUI modelThings.remove(oldThing); modelThings.add(newThing); logger.debug("Refreshing thing \'{}\' after successful retry", newThing.getUID()); - if (!ThingHelper.equals(oldThing, newThing)) { + if (!ThingHelper.equals(oldThing, newThing) + && !YamlModelRepositoryImpl.isIsolatedModel(entry.getKey())) { notifyListenersAboutUpdatedElement(oldThing, newThing); } break; 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 2f35f7a0a29..17fd0614c96 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,26 +12,33 @@ */ package org.openhab.core.model.yaml.internal.things.fileconverter; +import java.io.ByteArrayInputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.config.core.ConfigDescriptionRegistry; import org.openhab.core.i18n.LocaleProvider; import org.openhab.core.items.ItemUtil; import org.openhab.core.model.yaml.YamlElement; import org.openhab.core.model.yaml.YamlModelRepository; +import org.openhab.core.model.yaml.internal.items.YamlChannelLinkProvider; 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.link.ItemChannelLink; import org.openhab.core.thing.type.ChannelKind; import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeRegistry; @@ -48,20 +55,26 @@ * @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 YamlChannelLinkProvider itemChannelLinkProvider; private final LocaleProvider localeProvider; @Activate public YamlThingFileConverter(final @Reference YamlModelRepository modelRepository, + final @Reference YamlThingProvider thingProvider, + final @Reference YamlChannelLinkProvider itemChannelLinkProvider, 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.itemChannelLinkProvider = itemChannelLinkProvider; this.localeProvider = localeProvider; } @@ -71,15 +84,21 @@ public String getFileFormatGenerator() { } @Override - public synchronized void generateFileFormat(OutputStream out, List things, boolean hideDefaultParameters) { + public void setThingsToBeGenerated(String id, 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); + modelRepository.addElementsToBeGenerated(id, elements); } - private YamlThingDTO buildThingDTO(Thing thing, boolean hideDefaultParameters) { + @Override + public void generateFileFormat(String id, OutputStream out) { + modelRepository.generateFileFormat(id, out); + } + + 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 +121,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; @@ -143,4 +163,30 @@ private YamlChannelDTO buildChannelDTO(Channel channel, boolean hideDefaultParam return dto; } + + @Override + public String getFileFormatParser() { + return "YAML"; + } + + @Override + public @Nullable String startParsingFileFormat(String syntax, List errors, List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + return modelRepository.createIsolatedModel(inputStream, errors, warnings); + } + + @Override + public Collection getParsedThings(String modelName) { + return thingProvider.getAllFromModel(modelName); + } + + @Override + public Collection getParsedChannelLinks(String modelName) { + return itemChannelLinkProvider.getAllFromModel(modelName); + } + + @Override + public void finishParsingFileFormat(String modelName) { + modelRepository.removeIsolatedModel(modelName); + } } 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..6baab83a238 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 @@ -34,11 +34,21 @@ public interface ThingFileGenerator { String getFileFormatGenerator(); /** - * Generate the file format for a sorted list of things. + * Define the list of things to be generated and associate them to an identifier. * - * @param out the output stream to write the generated syntax to + * @param id the identifier of the file format generation * @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 setThingsToBeGenerated(String id, List things, boolean hideDefaultChannels, + boolean hideDefaultParameters); + + /** + * Generate the file format for all data that were associated to the provided identifier. + * + * @param id the identifier of the file format generation + * @param out the output stream to write to + */ + void generateFileFormat(String id, OutputStream out); } 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..7edcd2a1e9d --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileParser.java @@ -0,0 +1,71 @@ +/* + * 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.Collection; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.link.ItemChannelLink; + +/** + * {@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 without impacting the thing registry. + * + * @param syntax the syntax in file format + * @param errors the list to be used to fill the errors + * @param warnings the list to be used to fill the warnings + * @return the model name used for parsing if the parsing succeeded without errors; null otherwise + */ + @Nullable + String startParsingFileFormat(String syntax, List errors, List warnings); + + /** + * Get the {@link Thing} objects found when parsing the file format. + * + * @param modelName the model name used for parsing + * @return the collection of things + */ + Collection getParsedThings(String modelName); + + /** + * Get the {@link ItemChannelLink} objects found when parsing the file format. + * + * @param modelName the model name used for parsing + * @return the collection of items channel links + */ + Collection getParsedChannelLinks(String modelName); + + /** + * Release the data from a previously started file format parsing. + * + * @param modelName the model name used for parsing + */ + void finishParsingFileFormat(String modelName); +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileGenerator.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileGenerator.java index dc19773edf9..9db42db7f92 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileGenerator.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileGenerator.java @@ -36,13 +36,22 @@ public interface ItemFileGenerator { String getFileFormatGenerator(); /** - * Generate the file format for a sorted list of items. + * Define the list of items (including metadata and channel links) to be generated and associate them + * to an identifier. * - * @param out the output stream to write the generated syntax to + * @param id the identifier of the file format generation * @param items the items * @param metadata the provided collection of metadata for these items (including channel links) * @param hideDefaultParameters true to hide the configuration parameters having the default value */ - void generateFileFormat(OutputStream out, List items, Collection metadata, + void setItemsToBeGenerated(String id, List items, Collection metadata, boolean hideDefaultParameters); + + /** + * Generate the file format for all data that were associated to the provided identifier. + * + * @param id the identifier of the file format generation + * @param out the output stream to write to + */ + void generateFileFormat(String id, OutputStream out); } 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..7658406986a --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileParser.java @@ -0,0 +1,71 @@ +/* + * 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.Collection; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +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 without impacting the item and metadata registries. + * + * @param syntax the syntax in file format + * @param errors the list to be used to fill the errors + * @param warnings the list to be used to fill the warnings + * @return the model name used for parsing if the parsing succeeded without errors; null otherwise + */ + @Nullable + String startParsingFileFormat(String syntax, List errors, List warnings); + + /** + * Get the {@link Item} objects found when parsing the file format. + * + * @param modelName the model name used for parsing + * @return the collection of items + */ + Collection getParsedItems(String modelName); + + /** + * Get the {@link Metadata} objects found when parsing the file format. + * + * @param modelName the model name used for parsing + * @return the collection of metadata + */ + Collection getParsedMetadata(String modelName); + + /** + * Release the data from a previously started file format parsing. + * + * @param modelName the model name used for parsing + */ + void finishParsingFileFormat(String modelName); +} diff --git a/itests/org.openhab.core.model.item.tests/src/main/java/org/openhab/core/model/item/internal/GenericMetadataProviderTest.java b/itests/org.openhab.core.model.item.tests/src/main/java/org/openhab/core/model/item/internal/GenericMetadataProviderTest.java index 6abf0083153..616d3437ec3 100644 --- a/itests/org.openhab.core.model.item.tests/src/main/java/org/openhab/core/model/item/internal/GenericMetadataProviderTest.java +++ b/itests/org.openhab.core.model.item.tests/src/main/java/org/openhab/core/model/item/internal/GenericMetadataProviderTest.java @@ -37,7 +37,7 @@ public void testGetAllEmpty() { @Test public void testAddMetadata() { GenericMetadataProvider provider = new GenericMetadataProvider(); - provider.addMetadata("binding", "item", "value", null); + provider.addMetadata("model", false, "binding", "item", "value", null); Collection res = provider.getAll(); assertEquals(1, res.size()); assertEquals("value", res.iterator().next().getValue()); @@ -46,27 +46,27 @@ public void testAddMetadata() { @Test public void testRemoveMetadataNonExistentItem() { GenericMetadataProvider provider = new GenericMetadataProvider(); - provider.removeMetadataByItemName("nonExistentItem"); + provider.removeMetadataByItemName("model", "nonExistentItem"); } @Test public void testRemoveMetadataByItemName() { GenericMetadataProvider provider = new GenericMetadataProvider(); - provider.addMetadata("other", "item", "value", null); - provider.addMetadata("binding", "item", "value", null); - provider.addMetadata("binding", "other", "value", null); + provider.addMetadata("model", false, "other", "item", "value", null); + provider.addMetadata("model", false, "binding", "item", "value", null); + provider.addMetadata("model", false, "binding", "other", "value", null); assertEquals(3, provider.getAll().size()); - provider.removeMetadataByItemName("item"); + provider.removeMetadataByItemName("model", "item"); assertEquals(1, provider.getAll().size()); } @Test public void testRemoveMetadataByNamespace() { GenericMetadataProvider provider = new GenericMetadataProvider(); - provider.addMetadata("other", "item", "value", null); - provider.addMetadata("binding", "item", "value", null); - provider.addMetadata("binding", "other", "value", null); + provider.addMetadata("model", false, "other", "item", "value", null); + provider.addMetadata("model", false, "binding", "item", "value", null); + provider.addMetadata("model", false, "binding", "other", "value", null); assertEquals(3, provider.getAll().size()); provider.removeMetadataByNamespace("binding");