[REST] New API for conversion between file format and JSON (#4793)

* [REST] New API for conversion between file format and JSON

Related to #4585

This PR supports conversion of things and items.
It supports DSL and YAML file formats.

A first new API (POST /file-format/create) allows to create a file format (DSL or YAML) from a JSON object.
A second new API (POST /file-format/parse) allows to parse a file format (DSL or YAML) to a JSON object.

These 2 APIs should help Main UI displaying DSL and YAML file formats for things.

Signed-off-by: Laurent Garnier <lg.hc@free.fr>
This commit is contained in:
lolodomo
2025-09-21 11:48:33 +02:00
committed by GitHub
parent 0f7049347d
commit 00b6a476a7
29 changed files with 1865 additions and 336 deletions
@@ -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<String> warnings;
}
@@ -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<String, Object> configuration;
public FileFormatChannelLinkDTO(String channelUID, Map<String, Object> configuration) {
this.channelUID = channelUID;
this.configuration = configuration;
}
}
@@ -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<FileFormatItemDTO> items;
public List<ThingDTO> things;
}
@@ -0,0 +1,48 @@
/*
* 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 String format;
public Map<String, MetadataDTO> metadata;
public List<FileFormatChannelLinkDTO> 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;
}
}
}
@@ -0,0 +1,135 @@
/*
* 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 format the format to be used to format the item state, can be NULL
* @param channelLinks some items channel links
* @return file format item DTO object
*/
public static FileFormatItemDTO map(Item item, Collection<Metadata> metadata, @Nullable String format,
Collection<ItemChannelLink> channelLinks) {
ItemDTO itemDto = ItemDTOMapper.map(item);
FileFormatItemDTO dto = new FileFormatItemDTO(itemDto, itemDto instanceof GroupItemDTO);
dto.format = format;
Map<String, MetadataDTO> 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<FileFormatChannelLinkDTO> 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<Metadata> mapMetadata(FileFormatItemDTO dto) {
String name = dto.name;
Collection<Metadata> 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<String, MetadataDTO> 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;
}
}
@@ -16,17 +16,20 @@ import static org.openhab.core.config.discovery.inbox.InboxPredicates.forThingUI
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,41 @@ import org.openhab.core.config.discovery.DiscoveryResult;
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.openhab.core.types.StateDescription;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
@@ -86,6 +106,7 @@ import io.swagger.v3.oas.annotations.Operation;
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 +119,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*
* @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 +197,92 @@ public class FileFormatResource implements RESTResource {
param: my param value
""";
private static final String YAML_ITEMS_AND_THINGS_EXAMPLE = """
version: 1
things:
binding:typeBridge:idBridge:
isBridge: true
label: Label bridge
location: Location bridge
config:
stringParam: my value
binding:type:idBridge:id:
bridge: binding:typeBridge:idBridge
label: Label thing
location: Location thing
config:
booleanParam: true
decimalParam: 2.5
items:
Group1:
type: Group
label: Label
Group2:
type: Group
group:
type: Switch
function: Or
parameters:
- "ON"
- "OFF"
label: Label
MyItem:
type: Switch
label: Label
icon: icon
groups:
- Group1
- Group2
tags:
- Tag1
- Tag2
channel: binding:type:idBridge:id:channelid
metadata:
namespace:
value: my value
config:
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<String, ItemFileGenerator> itemFileGenerators = new ConcurrentHashMap<>();
private final Map<String, ItemFileParser> itemFileParsers = new ConcurrentHashMap<>();
private final Map<String, ThingFileGenerator> thingFileGenerators = new ConcurrentHashMap<>();
private final Map<String, ThingFileParser> 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 +299,15 @@ public class FileFormatResource implements RESTResource {
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 +317,15 @@ public class FileFormatResource implements RESTResource {
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 +363,17 @@ public class FileFormatResource implements RESTResource {
}
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
generator.generateFileFormat(outputStream, items, getMetadata(items), hideDefaultParameters);
String genId = newIdForGeneration();
Map<String, String> stateFormatters = new HashMap<>();
items.forEach(item -> {
StateDescription stateDescr = item.getStateDescription();
String format = stateDescr == null ? null : stateDescr.getPattern();
if (format != null) {
stateFormatters.put(item.getName(), format);
}
});
generator.setItemsToBeGenerated(genId, items, getMetadata(items), stateFormatters, hideDefaultParameters);
generator.generateFileFormat(genId, outputStream);
return Response.ok(new String(outputStream.toByteArray())).build();
}
@@ -301,10 +410,200 @@ public class FileFormatResource implements RESTResource {
}
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
generator.generateFileFormat(outputStream, things, hideDefaultParameters);
String genId = newIdForGeneration();
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_ITEMS_AND_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<Thing> things = new ArrayList<>();
List<Item> items = new ArrayList<>();
List<Metadata> metadata = new ArrayList<>();
Map<String, String> stateFormatters = new HashMap<>();
List<String> errors = new ArrayList<>();
if (!convertFromFileFormatDTO(data, things, items, metadata, stateFormatters, 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 = newIdForGeneration();
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,
stateFormatters, 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, stateFormatters, 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_ITEMS_AND_THINGS_EXAMPLE)) }) String input) {
String contentTypeHeader = httpHeaders.getHeaderString(HttpHeaders.CONTENT_TYPE);
logger.debug("parse: contentType = {}", contentTypeHeader);
// First parse the input
Collection<Thing> things = List.of();
Collection<Item> items = List.of();
Collection<Metadata> metadata = List.of();
Collection<ItemChannelLink> channelLinks = List.of();
Map<String, String> stateFormatters = Map.of();
List<String> errors = new ArrayList<>();
List<String> 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);
stateFormatters = itemParser.getParsedStateFormatters(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();
}
}
String modelNameToUse = modelName != null ? modelName : Objects.requireNonNull(modelName2);
items = itemParser.getParsedItems(modelNameToUse);
metadata = itemParser.getParsedMetadata(modelNameToUse);
stateFormatters = itemParser.getParsedStateFormatters(modelNameToUse);
}
break;
default:
return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE)
.entity("Unsupported content type '" + contentTypeHeader + "'!").build();
}
ExtendedFileFormatDTO result = convertToFileFormatDTO(things, items, metadata, stateFormatters, channelLinks,
warnings);
if (modelName != null && thingParser != null) {
thingParser.finishParsingFileFormat(modelName);
}
if (modelName2 != null && itemParser != null) {
itemParser.finishParsingFileFormat(modelName2);
}
return Response.ok(result).build();
}
private String newIdForGeneration() {
return GEN_ID_PATTERN.formatted(++counter);
}
/*
* Get all the metadata for a list of items including channel links mapped to metadata in the namespace "channel"
*/
@@ -465,6 +764,23 @@ public class FileFormatResource implements RESTResource {
};
}
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<Thing> getThingsOrDiscoveryResult(List<String> thingUIDs) {
return thingUIDs.stream().distinct().map(uid -> {
ThingUID thingUID = new ThingUID(uid);
@@ -485,4 +801,218 @@ public class FileFormatResource implements RESTResource {
return simulateThing(discoveryResult, thingType);
}).toList();
}
private boolean convertFromFileFormatDTO(FileFormatDTO data, List<Thing> things, List<Item> items,
List<Metadata> metadata, Map<String, String> stateFormatters, List<String> errors) {
boolean ok = true;
if (data.things != null) {
for (ThingDTO thingData : data.things) {
ThingUID thingUID = thingData.UID == null ? null : new ThingUID(thingData.UID);
ThingTypeUID thingTypeUID = new ThingTypeUID(thingData.thingTypeUID);
ThingUID bridgeUID = null;
if (thingData.bridgeUID != null) {
bridgeUID = new ThingUID(thingData.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(thingData.configuration, thingTypeUID, thingUID));
if (thingUID != null) {
normalizeChannels(thingData, thingUID);
}
Thing thing = thingRegistry.createThingOfType(thingTypeUID, thingUID, bridgeUID, thingData.label,
configuration);
if (thing != null) {
if (thingData.properties != null) {
for (Map.Entry<String, String> entry : thingData.properties.entrySet()) {
thing.setProperty(entry.getKey(), entry.getValue());
}
}
if (thingData.location != null) {
thing.setLocation(thingData.location);
}
if (thingData.channels != null) {
// The provided channels replace the channels provided by the thing type.
ThingDTO thingChannels = new ThingDTO();
thingChannels.channels = thingData.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(thingData,
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));
if (itemData.format != null) {
stateFormatters.put(name, itemData.format);
}
}
}
return ok;
}
private ExtendedFileFormatDTO convertToFileFormatDTO(Collection<Thing> things, Collection<Item> items,
Collection<Metadata> metadata, Map<String, String> stateFormatters,
Collection<ItemChannelLink> channelLinks, List<String> 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, stateFormatters.get(item.getName()), channelLinks));
});
}
return dto;
}
private @Nullable Map<String, @Nullable Object> normalizeConfiguration(
@Nullable Map<String, @Nullable Object> properties, ThingTypeUID thingTypeUID,
@Nullable ThingUID thingUID) {
if (properties == null || properties.isEmpty()) {
return properties;
}
ThingType thingType = thingTypeRegistry.getThingType(thingTypeUID);
if (thingType == null) {
return properties;
}
List<ConfigDescription> 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<String, @Nullable Object> normalizeConfiguration(Map<String, @Nullable Object> properties,
ChannelTypeUID channelTypeUID, ChannelUID channelUID) {
if (properties == null || properties.isEmpty()) {
return properties;
}
ChannelType channelType = channelTypeRegistry.getChannelType(channelTypeUID);
if (channelType == null) {
return properties;
}
List<ConfigDescription> 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);
}
}
}
@@ -24,4 +24,18 @@ public class ModelCoreConstants {
/** The service pid used for the managed service (without the "org.openhab.core" prefix */
public static final String SERVICE_PID = "folder";
public static final String PREFIX_TMP_MODEL = "tmp_";
/**
* 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
*/
public static boolean isIsolatedModel(String modelName) {
return modelName.startsWith(PREFIX_TMP_MODEL);
}
}
@@ -14,6 +14,7 @@ package org.openhab.core.model.core;
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,7 @@ import org.eclipse.jdt.annotation.Nullable;
* come from.
*
* @author Kai Kreuzer - Initial contribution
* @author Laurent Garnier - Added method generateSyntaxFromModel
* @author Laurent Garnier - Added methods generateFileFormat and createIsolatedModel
*/
@NonNullByDefault
public interface ModelRepository {
@@ -96,11 +97,25 @@ 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<String> errors, List<String> warnings);
/**
* 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);
}
@@ -12,6 +12,8 @@
*/
package org.openhab.core.model.core.internal;
import static org.openhab.core.model.core.ModelCoreConstants.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -20,7 +22,6 @@ import java.nio.charset.StandardCharsets;
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,7 +52,8 @@ import org.slf4j.LoggerFactory;
* @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 methods generateFileFormat and createIsolatedModel
* + return errors and warnings when loading a model
*/
@Component(immediate = true)
@NonNullByDefault
@@ -87,12 +89,12 @@ public class ModelRepositoryImpl implements ModelRepository {
if (!resource.getContents().isEmpty()) {
return resource.getContents().getFirst();
} else {
logger.warn("Configuration model '{}' is either empty or cannot be parsed correctly!", name);
logger.warn("DSL model '{}' is either empty or cannot be parsed correctly!", name);
resourceSet.getResources().remove(resource);
return null;
}
} else {
logger.trace("Configuration model '{}' can not be found", name);
logger.trace("DSL model '{}' can not be found", name);
return null;
}
}
@@ -100,19 +102,39 @@ public class ModelRepositoryImpl implements ModelRepository {
@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<String> errors,
@Nullable List<String> 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<String> newErrors = new ArrayList<>();
List<String> newWarnings = new ArrayList<>();
boolean valid = validateModel(name, new ByteArrayInputStream(bytes), newErrors, newWarnings);
if (errors != null) {
errors.addAll(newErrors);
}
if (warnings != null) {
warnings.addAll(newWarnings);
}
if (!valid) {
logger.warn("DSL model '{}' has errors, therefore ignoring it: {}", name, String.join("\n", newErrors));
removeModel(name);
return false;
}
if (!newWarnings.isEmpty()) {
logger.info("Validation issues found in DSL model '{}', using it anyway:\n{}", name,
String.join("\n", newWarnings));
}
} catch (IOException e) {
logger.warn("Configuration model '{}' cannot be parsed correctly!", name, e);
if (errors != null) {
errors.add("Model cannot be parsed correctly: %s".formatted(e.getMessage()));
}
logger.warn("DSL model '{}' cannot be parsed correctly!", name, e);
return false;
}
try (InputStream inputStream = new ByteArrayInputStream(bytes)) {
@@ -144,7 +166,10 @@ public class ModelRepositoryImpl implements ModelRepository {
}
}
} 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);
}
@@ -154,6 +179,7 @@ public class ModelRepositoryImpl implements ModelRepository {
@Override
public boolean removeModel(String name) {
logger.info("Unloading DSL model '{}'", name);
Resource resource = getResource(name);
if (resource != null) {
synchronized (resourceSet) {
@@ -176,7 +202,7 @@ public class ModelRepositoryImpl implements ModelRepository {
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 class ModelRepositoryImpl implements ModelRepository {
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 class ModelRepositoryImpl implements ModelRepository {
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,22 @@ public class ModelRepositoryImpl implements ModelRepository {
}
@Override
public void generateSyntaxFromModel(OutputStream out, String modelType, EObject modelContent) {
public @Nullable String createIsolatedModel(String modelType, InputStream inputStream, List<String> errors,
List<String> warnings) {
String name = "%sDSL_model_%d.%s".formatted(PREFIX_TMP_MODEL, ++counter, modelType);
return addOrRefreshModel(name, inputStream, errors, warnings) ? name : null;
}
@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_DSL_%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 +301,28 @@ public class ModelRepositoryImpl implements ModelRepository {
* 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, <code>null</code> 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<String> errors, List<String> 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<String> 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 +332,6 @@ public class ModelRepositoryImpl implements ModelRepository {
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 +340,7 @@ public class ModelRepositoryImpl implements ModelRepository {
} finally {
resourceSet.getResources().remove(resource);
}
return null;
return true;
}
private void notifyListeners(String name, EventType type) {
@@ -12,6 +12,8 @@
*/
package org.openhab.core.model.item.internal;
import static org.openhab.core.model.core.ModelCoreConstants.isIsolatedModel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -67,6 +69,7 @@ import org.slf4j.LoggerFactory;
*
* @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, GenericItemProvider.class,
@@ -87,6 +90,7 @@ public class GenericItemProvider extends AbstractProvider<Item>
private final Collection<ItemFactory> itemFactorys = new ArrayList<>();
private final Map<String, Map<String, String>> stateFormattersMap = new ConcurrentHashMap<>();
private final Map<String, StateDescriptionFragment> stateDescriptionFragments = new ConcurrentHashMap<>();
private Integer rank;
@@ -171,6 +175,14 @@ public class GenericItemProvider extends AbstractProvider<Item>
return items;
}
public Collection<Item> getAllFromModel(String modelName) {
return itemsMap.getOrDefault(modelName, List.of());
}
public Map<String, String> getStateFormattersFromModel(String modelName) {
return stateFormattersMap.getOrDefault(modelName, Map.of());
}
private Collection<Item> getItemsFromModel(String modelName) {
logger.debug("Read items from model '{}'", modelName);
@@ -178,7 +190,7 @@ public class GenericItemProvider extends AbstractProvider<Item>
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);
@@ -207,8 +219,8 @@ public class GenericItemProvider extends AbstractProvider<Item>
// 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());
}
@@ -221,7 +233,7 @@ public class GenericItemProvider extends AbstractProvider<Item>
}
}
private @Nullable Item createItemFromModelItem(ModelItem modelItem) {
private @Nullable Item createItemFromModelItem(ModelItem modelItem, String modelName) {
Item item;
if (modelItem instanceof ModelGroupItem modelGroupItem) {
Item baseItem;
@@ -254,10 +266,24 @@ public class GenericItemProvider extends AbstractProvider<Item>
String format = extractFormat(label);
if (format != null) {
label = label.substring(0, label.indexOf("[")).trim();
stateDescriptionFragments.put(modelItem.getName(),
StateDescriptionFragmentBuilder.create().withPattern(format).build());
Map<String, String> formatters = Objects
.requireNonNull(stateFormattersMap.computeIfAbsent(modelName, k -> new HashMap<>()));
formatters.put(modelItem.getName(), format);
if (!isIsolatedModel(modelName)) {
stateDescriptionFragments.put(modelItem.getName(),
StateDescriptionFragmentBuilder.create().withPattern(format).build());
}
} else {
stateDescriptionFragments.remove(modelItem.getName());
Map<String, String> formatters = stateFormattersMap.get(modelName);
if (formatters != null) {
formatters.remove(modelItem.getName());
if (formatters.isEmpty()) {
stateFormattersMap.remove(modelName);
}
}
if (!isIsolatedModel(modelName)) {
stateDescriptionFragments.remove(modelItem.getName());
}
}
activeItem.setLabel(label);
activeItem.setCategory(modelItem.getIcon());
@@ -304,7 +330,7 @@ public class GenericItemProvider extends AbstractProvider<Item>
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());
}
@@ -325,7 +351,7 @@ public class GenericItemProvider extends AbstractProvider<Item>
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());
}
@@ -390,7 +416,8 @@ public class GenericItemProvider extends AbstractProvider<Item>
bindingType, item.getName(), e);
}
} else {
genericMetaDataProvider.addMetadata(bindingType, item.getName(), config, configuration.getProperties());
genericMetaDataProvider.addMetadata(modelName, bindingType, item.getName(), config,
configuration.getProperties());
}
}
}
@@ -404,20 +431,22 @@ public class GenericItemProvider extends AbstractProvider<Item>
Map<String, Item> oldItems = toItemMap(itemsMap.get(modelName));
Map<String, Item> 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 (!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,18 +454,21 @@ public class GenericItemProvider extends AbstractProvider<Item>
processBindingConfigsFromModel(modelName, type);
Collection<Item> itemsFromModel = getItemsFromModel(modelName);
itemsMap.remove(modelName);
stateFormattersMap.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 (!isIsolatedModel(modelName)) {
notifyListenersAboutRemovedElement(oldItem);
this.stateDescriptionFragments.remove(oldItem.getName());
}
genericMetaDataProvider.removeMetadataByItemName(modelName, oldItem.getName());
}
protected boolean hasItemChanged(Item item1, Item item2) {
@@ -13,10 +13,13 @@
package org.openhab.core.model.item.internal;
import static java.util.stream.Collectors.toSet;
import static org.openhab.core.model.core.ModelCoreConstants.isIsolatedModel;
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 +41,37 @@ import org.osgi.service.component.annotations.Component;
* 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<Metadata> implements MetadataProvider {
private final Set<Metadata> metadata = new HashSet<>();
private final Map<String, Set<Metadata>> metadata = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock(true);
/**
* Adds metadata to this provider
*
* @param modelName the model name
* @param bindingType
* @param itemName
* @param configuration
*/
public void addMetadata(String bindingType, String itemName, String value,
public void addMetadata(String modelName, String bindingType, String itemName, String value,
@Nullable Map<String, Object> configuration) {
MetadataKey key = new MetadataKey(bindingType, itemName);
Metadata md = new Metadata(key, value, configuration);
try {
lock.writeLock().lock();
metadata.add(md);
Set<Metadata> mdSet = Objects.requireNonNull(metadata.computeIfAbsent(modelName, k -> new HashSet<>()));
mdSet.add(md);
} finally {
lock.writeLock().unlock();
}
notifyListenersAboutAddedElement(md);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(md);
}
}
/**
@@ -72,41 +80,75 @@ public class GenericMetadataProvider extends AbstractProvider<Metadata> implemen
* @param namespace the namespace
*/
public void removeMetadataByNamespace(String namespace) {
Set<Metadata> toBeRemoved;
Map<String, Set<Metadata>> toBeNotified;
try {
lock.writeLock().lock();
toBeRemoved = metadata.stream().filter(MetadataPredicates.hasNamespace(namespace)).collect(toSet());
metadata.removeAll(toBeRemoved);
toBeNotified = new HashMap<>();
for (Map.Entry<String, Set<Metadata>> entry : metadata.entrySet()) {
String modelName = entry.getKey();
Set<Metadata> mdSet = entry.getValue();
Set<Metadata> toBeRemoved = mdSet.stream().filter(MetadataPredicates.hasNamespace(namespace))
.collect(toSet());
mdSet.removeAll(toBeRemoved);
if (mdSet.isEmpty()) {
metadata.remove(modelName);
}
if (!isIsolatedModel(modelName) && !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<Metadata> toBeRemoved;
public void removeMetadataByItemName(String modelName, String itemName) {
Set<Metadata> toBeNotified;
try {
lock.writeLock().lock();
toBeRemoved = metadata.stream().filter(MetadataPredicates.ofItem(itemName)).collect(toSet());
metadata.removeAll(toBeRemoved);
toBeNotified = new HashSet<>();
Set<Metadata> mdSet = metadata.getOrDefault(modelName, new HashSet<>());
Set<Metadata> toBeRemoved = mdSet.stream().filter(MetadataPredicates.ofItem(itemName)).collect(toSet());
mdSet.removeAll(toBeRemoved);
if (mdSet.isEmpty()) {
metadata.remove(modelName);
}
if (!isIsolatedModel(modelName) && !toBeRemoved.isEmpty()) {
toBeNotified.addAll(toBeRemoved);
}
} finally {
lock.writeLock().unlock();
}
for (Metadata m : toBeRemoved) {
notifyListenersAboutRemovedElement(m);
}
toBeNotified.forEach(this::notifyListenersAboutRemovedElement);
}
@Override
public Collection<Metadata> getAll() {
try {
lock.readLock().lock();
return Set.copyOf(metadata);
// Ignore isolated models
Set<Metadata> set = metadata.keySet().stream().filter(name -> !isIsolatedModel(name))
.map(name -> metadata.getOrDefault(name, Set.of())).flatMap(s -> s.stream()).collect(toSet());
return Set.copyOf(set);
} finally {
lock.readLock().unlock();
}
}
public Collection<Metadata> getAllFromModel(String modelName) {
try {
lock.readLock().lock();
Set<Metadata> set = metadata.getOrDefault(modelName, Set.of());
return Set.copyOf(set);
} finally {
lock.readLock().unlock();
}
@@ -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.HashSet;
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.Item;
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;
@@ -45,7 +50,6 @@ import org.openhab.core.model.items.ModelGroupItem;
import org.openhab.core.model.items.ModelItem;
import org.openhab.core.model.items.ModelProperty;
import org.openhab.core.types.State;
import org.openhab.core.types.StateDescription;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
@@ -59,18 +63,26 @@ import org.slf4j.LoggerFactory;
* @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<String, ItemModel> 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,21 +92,29 @@ public class DslItemFileConverter extends AbstractItemFileGenerator {
}
@Override
public synchronized void generateFileFormat(OutputStream out, List<Item> items, Collection<Metadata> metadata,
boolean hideDefaultParameters) {
public void setItemsToBeGenerated(String id, List<Item> items, Collection<Metadata> metadata,
Map<String, String> stateFormatters, boolean hideDefaultParameters) {
if (items.isEmpty()) {
return;
}
ItemModel model = ItemsFactory.eINSTANCE.createItemModel();
for (Item item : items) {
model.getItems().add(buildModelItem(item, getChannelLinks(metadata, item.getName()),
getMetadata(metadata, item.getName()), hideDefaultParameters));
getMetadata(metadata, item.getName()), stateFormatters.get(item.getName()), hideDefaultParameters));
}
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);
}
modelRepository.generateSyntaxFromModel(out, "items", model);
}
private ModelItem buildModelItem(Item item, List<Metadata> channelLinks, List<Metadata> metadata,
boolean hideDefaultParameters) {
@Nullable String stateFormatter, boolean hideDefaultParameters) {
ModelItem model;
if (item instanceof GroupItem groupItem) {
ModelGroupItem modelGroup = ItemsFactory.eINSTANCE.createModelGroupItem();
@@ -123,9 +143,8 @@ public class DslItemFileConverter extends AbstractItemFileGenerator {
boolean patternInjected = false;
String defaultPattern = getDefaultStatePattern(item);
if (label != null && !label.isEmpty()) {
StateDescription stateDescr = item.getStateDescription();
String statePattern = stateDescr == null ? null : stateDescr.getPattern();
String patterToInject = statePattern != null && !statePattern.equals(defaultPattern) ? statePattern : null;
String patterToInject = stateFormatter != null && !stateFormatter.equals(defaultPattern) ? stateFormatter
: null;
if (patterToInject != null) {
// Inject the pattern in the label
patternInjected = true;
@@ -279,4 +298,35 @@ public class DslItemFileConverter extends AbstractItemFileGenerator {
}
return parameters;
}
@Override
public String getFileFormatParser() {
return "DSL";
}
@Override
public @Nullable String startParsingFileFormat(String syntax, List<String> errors, List<String> warnings) {
ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes());
return modelRepository.createIsolatedModel("items", inputStream, errors, warnings);
}
@Override
public Collection<Item> getParsedItems(String modelName) {
return itemProvider.getAllFromModel(modelName);
}
@Override
public Collection<Metadata> getParsedMetadata(String modelName) {
return metadataProvider.getAllFromModel(modelName);
}
@Override
public Map<String, String> getParsedStateFormatters(String modelName) {
return itemProvider.getStateFormattersFromModel(modelName);
}
@Override
public void finishParsingFileFormat(String modelName) {
modelRepository.removeModel(modelName);
}
}
@@ -12,6 +12,8 @@
*/
package org.openhab.core.model.thing.internal;
import static org.openhab.core.model.core.ModelCoreConstants.isIsolatedModel;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
@@ -40,15 +42,18 @@ import org.slf4j.LoggerFactory;
*
* @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<ItemChannelLink>
implements BindingConfigReader, ItemChannelLinkProvider {
private final Logger logger = LoggerFactory.getLogger(GenericItemChannelLinkProvider.class);
/** caches binding configurations. maps itemNames to {@link ItemChannelLink}s */
protected Map<String, Map<ChannelUID, ItemChannelLink>> itemChannelLinkMap = new ConcurrentHashMap<>();
/** caches binding configurations. maps context to a map mapping itemNames to {@link ItemChannelLink}s */
protected Map<String, Map<String, Map<ChannelUID, ItemChannelLink>>> itemChannelLinkMap = new ConcurrentHashMap<>();
private Map<String, Set<ChannelUID>> addedItemChannels = new ConcurrentHashMap<>();
@@ -110,17 +115,21 @@ public class GenericItemChannelLinkProvider extends AbstractProvider<ItemChannel
previousItemNames.remove(itemName);
}
Map<String, Map<ChannelUID, ItemChannelLink>> 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<ChannelUID, ItemChannelLink> 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 +150,48 @@ public class GenericItemChannelLinkProvider extends AbstractProvider<ItemChannel
if (previousItemNames == null) {
return;
}
Map<String, Map<ChannelUID, ItemChannelLink>> channelLinkMap = (itemChannelLinkMap.getOrDefault(context,
new HashMap<>()));
for (String itemName : previousItemNames) {
// we remove all binding configurations that were not processed
Map<ChannelUID, ItemChannelLink> links = itemChannelLinkMap.remove(itemName);
if (links != null) {
Map<ChannelUID, ItemChannelLink> 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<ChannelUID, ItemChannelLink> links = itemChannelLinkMap.getOrDefault(itemName, Map.of());
Map<ChannelUID, ItemChannelLink> links = channelLinkMap.getOrDefault(itemName, Map.of());
Set<ChannelUID> 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<ItemChannelLink> 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<ItemChannelLink> 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 !isIsolatedModel(context);
}
}
@@ -12,6 +12,8 @@
*/
package org.openhab.core.model.thing.internal
import static org.openhab.core.model.core.ModelCoreConstants.isIsolatedModel
import java.util.ArrayList
import java.util.Collection
import java.util.HashSet
@@ -71,8 +73,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<Thing> implements ThingProvider, ModelRepositoryChangeListener, ReadyService.ReadyTracker {
static final String XML_THING_TYPE = "openhab.xmlThingTypes";
@@ -108,7 +111,18 @@ class GenericThingProvider extends AbstractProviderLazyNullness<Thing> implement
}
override Collection<Thing> getAll() {
thingsMap.values.flatten.toList
val things = new ArrayList
thingsMap.keySet.filter[!isIsolatedModel(it)].forEach([
val things2 = thingsMap.get(it)
if (things2 !== null) {
things.addAll(things2)
}
])
return things
}
def public Collection<Thing> getAllFromModel(String modelName) {
thingsMap.getOrDefault(modelName, List.of())
}
def private void createThingsFromModel(String modelName) {
@@ -470,7 +484,9 @@ class GenericThingProvider extends AbstractProviderLazyNullness<Thing> implement
val removedThings = oldThings.filter[!newThingUIDs.contains(it.UID)]
removedThings.forEach [
logger.debug("Removing thing '{}' from model '{}'.", it.UID, modelName)
notifyListenersAboutRemovedElement
if (!isIsolatedModel(modelName)) {
notifyListenersAboutRemovedElement
}
]
createThingsFromModel(modelName)
thingsMap.get(modelName).removeAll(removedThings)
@@ -479,9 +495,11 @@ class GenericThingProvider extends AbstractProviderLazyNullness<Thing> 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 (!isIsolatedModel(modelName)) {
things.forEach [
notifyListenersAboutRemovedElement
]
}
}
}
}
@@ -621,12 +639,16 @@ class GenericThingProvider extends AbstractProviderLazyNullness<Thing> implement
things.remove(oldThing)
things.add(newThing)
logger.debug("Updating thing '{}' from model '{}'.", newThing.UID, modelName);
notifyListenersAboutUpdatedElement(oldThing, newThing)
if (!isIsolatedModel(modelName)) {
notifyListenersAboutUpdatedElement(oldThing, newThing)
}
}
} else {
things.add(newThing)
logger.debug("Adding thing '{}' from model '{}'.", newThing.UID, modelName);
newThing.notifyListenersAboutAddedElement
if (!isIsolatedModel(modelName)) {
newThing.notifyListenersAboutAddedElement
}
}
]
}
@@ -658,7 +680,7 @@ class GenericThingProvider extends AbstractProviderLazyNullness<Thing> 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) && !isIsolatedModel(modelName)) {
notifyListenersAboutUpdatedElement(oldThing, newThing)
}
} else {
@@ -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.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.ChannelTypeRegistry;
import org.openhab.core.thing.type.ChannelTypeUID;
@@ -53,20 +61,28 @@ import org.slf4j.LoggerFactory;
* @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<String, ThingModel> 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 class DslThingFileConverter extends AbstractThingFileGenerator {
}
@Override
public synchronized void generateFileFormat(OutputStream out, List<Thing> things, boolean hideDefaultParameters) {
public void setThingsToBeGenerated(String id, List<Thing> things, boolean hideDefaultChannels,
boolean hideDefaultParameters) {
if (things.isEmpty()) {
return;
}
@@ -85,23 +102,32 @@ public class DslThingFileConverter extends AbstractThingFileGenerator {
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<Thing> onlyThings, Set<Thing> handledThings) {
private ModelThing buildModelThing(Thing thing, boolean hideDefaultChannels, boolean hideDefaultParameters,
boolean preferPresentationAsTree, boolean topLevel, List<Thing> onlyThings, Set<Thing> handledThings) {
ModelThing model;
ModelBridge modelBridge;
List<Thing> childThings = getChildThings(thing, onlyThings);
@@ -141,13 +167,13 @@ public class DslThingFileConverter extends AbstractThingFileGenerator {
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<Channel> channels = getNonDefaultChannels(thing);
List<Channel> channels = hideDefaultChannels ? getNonDefaultChannels(thing) : thing.getChannels();
model.setChannelsHeader(!channels.isEmpty());
for (Channel channel : channels) {
model.getChannels().add(buildModelChannel(channel, hideDefaultParameters));
@@ -200,4 +226,30 @@ public class DslThingFileConverter extends AbstractThingFileGenerator {
}
return property;
}
@Override
public String getFileFormatParser() {
return "DSL";
}
@Override
public @Nullable String startParsingFileFormat(String syntax, List<String> errors, List<String> warnings) {
ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes());
return modelRepository.createIsolatedModel("things", inputStream, errors, warnings);
}
@Override
public Collection<Thing> getParsedThings(String modelName) {
return thingProvider.getAllFromModel(modelName);
}
@Override
public Collection<ItemChannelLink> getParsedChannelLinks(String modelName) {
return itemChannelLinkProvider.getAllFromContext(modelName);
}
@Override
public void finishParsingFileFormat(String modelName) {
modelRepository.removeModel(modelName);
}
}
@@ -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, createIsolatedModel and
* removeIsolatedModel
*/
@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<YamlElement> elements);
void addElementsToBeGenerated(String id, List<YamlElement> 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<String> errors, List<String> warnings);
/**
* Removes an isolated model from the repository
*
* @param modelName the name of the model to be removed
*/
void removeIsolatedModel(String modelName);
}
@@ -0,0 +1,38 @@
/*
* 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.model.yaml;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Static utility constants and methods that are helpful when dealing with YAML models.
*
* @author Laurent Garnier - Initial contribution
*/
@NonNullByDefault
public class YamlModelUtils {
public static final String PREFIX_TMP_MODEL = "tmp_";
/**
* 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
*/
public static boolean isIsolatedModel(String modelName) {
return modelName.startsWith(PREFIX_TMP_MODEL);
}
}
@@ -12,9 +12,11 @@
*/
package org.openhab.core.model.yaml.internal;
import static org.openhab.core.model.yaml.YamlModelUtils.*;
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 +78,9 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLParser;
* @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, createIsolatedModel and
* removeIsolatedModel
*/
@NonNullByDefault
@Component(immediate = true)
@@ -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 static final List<Path> WATCHED_PATHS = Stream.of("things", "items", "tags", "yaml").map(Path::of).toList();
@@ -105,6 +109,10 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
// all model nodes, ordered by model name (full path as string) and type
private final Map<String, YamlModelWrapper> modelCache = new ConcurrentHashMap<>();
private final Map<String, List<YamlElement>> elementsToGenerate = new ConcurrentHashMap<>();
private int counter;
@Activate
public YamlModelRepositoryImpl(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) {
YAMLFactory yamlFactory = YAMLFactory.builder() //
@@ -174,7 +182,6 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
// The method is "synchronized" to avoid concurrent files processing
@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public synchronized void processWatchEvent(Kind kind, Path fullPath) {
Path relativePath = mainWatchPath.relativize(fullPath);
String modelName = relativePath.toString();
@@ -183,136 +190,145 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
return;
}
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
try {
if (kind == WatchService.Kind.DELETE) {
removeModel(modelName);
} else if (!Files.isHidden(fullPath) && Files.isReadable(fullPath) && !Files.isDirectory(fullPath)) {
JsonNode fileContent = objectMapper.readTree(fullPath.toFile());
// check version
JsonNode versionNode = fileContent.get(VERSION);
if (versionNode == null || !versionNode.canConvertToInt()) {
logger.warn("Version is missing or not a number in model {}. Ignoring it.", modelName);
removeModel(modelName);
return;
}
int modelVersion = versionNode.asInt();
if (modelVersion < 1 || modelVersion > DEFAULT_MODEL_VERSION) {
logger.warn(
"Model {} has version {}, but only versions between 1 and {} are supported. Ignoring it.",
modelName, modelVersion, DEFAULT_MODEL_VERSION);
removeModel(modelName);
return;
}
if (kind == Kind.CREATE) {
logger.info("Adding YAML model {}", modelName);
} else {
logger.info("Updating YAML model {}", modelName);
}
JsonNode readOnlyNode = fileContent.get(READ_ONLY);
boolean readOnly = readOnlyNode == null || readOnlyNode.asBoolean(false);
YamlModelWrapper model = Objects.requireNonNull(
modelCache.computeIfAbsent(modelName, k -> new YamlModelWrapper(modelVersion, readOnly)));
List<String> newElementNames = new ArrayList<>();
// get sub-elements
Iterator<Map.Entry<String, JsonNode>> it = fileContent.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> 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.warn("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<? extends YamlElement> elementClass = elementListener.getElementClass();
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
Map<String, ? extends YamlElement> oldElements = listToMap(
parseJsonMapNode(oldNodeElements, elementClass, null, null));
Map<String, ? extends YamlElement> 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);
}
}
// 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;
}
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);
processModelContent(modelName, kind, objectMapper.readTree(fullPath.toFile()), errors, warnings);
} else {
logger.trace("Ignored {}", fullPath);
}
} catch (IOException e) {
logger.warn("Failed to process model {}: {}", modelName, e.getMessage());
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<String> errors,
List<String> 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<String> newElementNames = new ArrayList<>();
// get sub-elements
Iterator<Map.Entry<String, JsonNode>> it = fileContent.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> 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.warn("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<? extends YamlElement> elementClass = elementListener.getElementClass();
List<String> errors2 = new ArrayList<>();
List<String> warnings2 = new ArrayList<>();
Map<String, ? extends YamlElement> oldElements = listToMap(
parseJsonMapNode(oldNodeElements, elementClass, null, null));
Map<String, ? extends YamlElement> 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));
}
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);
}
// 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;
});
checkElementNames(modelName, model, warnings);
return valid;
}
@SuppressWarnings({ "rawtypes", "unchecked" })
@@ -364,11 +380,11 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
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);
});
}
@@ -380,12 +396,12 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
});
}
private void checkElementNames(String modelName, YamlModelWrapper model) {
private void checkElementNames(String modelName, YamlModelWrapper model, List<String> warnings) {
Set<String> elementListenerNames = elementListeners.keySet();
if (elementListenerNames.containsAll(KNOWN_ELEMENTS)) {
Set<String> 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));
});
}
}
@@ -529,7 +545,10 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
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;
@@ -563,7 +582,36 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
}
@Override
public void generateSyntaxFromElements(OutputStream out, List<YamlElement> elements) {
public synchronized @Nullable String createIsolatedModel(InputStream inputStream, List<String> errors,
List<String> 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);
}
}
@Override
public void addElementsToBeGenerated(String id, List<YamlElement> elements) {
List<YamlElement> elts = Objects.requireNonNull(elementsToGenerate.computeIfAbsent(id, k -> new ArrayList<>()));
elts.addAll(elements);
}
@Override
public void generateFileFormat(String id, OutputStream out) {
List<YamlElement> elements = elementsToGenerate.remove(id);
// create the model
JsonNodeFactory nodeFactory = objectMapper.getNodeFactory();
ObjectNode rootNode = nodeFactory.objectNode();
@@ -572,15 +620,17 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
// First separate elements per type
Map<String, List<YamlElement>> elementsPerTypes = new HashMap<>();
elements.forEach(element -> {
String elementName = getElementName(element.getClass());
List<YamlElement> 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<YamlElement> 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<String, YamlElement> mapElts = new LinkedHashMap<>();
@@ -640,9 +690,11 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
} 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, "")));
}
}
}
@@ -12,6 +12,8 @@
*/
package org.openhab.core.model.yaml.internal.items;
import static org.openhab.core.model.yaml.YamlModelUtils.isIsolatedModel;
import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
@@ -51,7 +53,9 @@ public class YamlChannelLinkProvider extends AbstractProvider<ItemChannelLink> i
@Override
public Collection<ItemChannelLink> getAll() {
return itemsChannelLinksMap.values().stream().flatMap(m -> m.values().stream())
// Ignore isolated models
return itemsChannelLinksMap.keySet().stream().filter(name -> !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 class YamlChannelLinkProvider extends AbstractProvider<ItemChannelLink> i
ItemChannelLink oldLink = links.get(channelUIDObject);
if (oldLink == null) {
links.put(channelUIDObject, itemChannelLink);
logger.debug("notify added item channel link {}", itemChannelLink.getUID());
notifyListenersAboutAddedElement(itemChannelLink);
logger.debug("model {} added channel link {}", modelName, itemChannelLink.getUID());
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(itemChannelLink);
}
} else if (!YamlElementUtils.equalsConfig(configuration.getProperties(),
oldLink.getConfiguration().getProperties())) {
links.put(channelUIDObject, itemChannelLink);
logger.debug("notify updated item channel link {}", itemChannelLink.getUID());
notifyListenersAboutUpdatedElement(oldLink, itemChannelLink);
logger.debug("model {} updated channel link {}", modelName, itemChannelLink.getUID());
if (!isIsolatedModel(modelName)) {
notifyListenersAboutUpdatedElement(oldLink, itemChannelLink);
}
}
}
linksToBeRemoved.forEach(uid -> {
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 (!isIsolatedModel(modelName)) {
notifyListenersAboutRemovedElement(link);
}
}
});
if (links.isEmpty()) {
@@ -12,6 +12,8 @@
*/
package org.openhab.core.model.yaml.internal.items;
import static org.openhab.core.model.yaml.YamlModelUtils.isIsolatedModel;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
@@ -75,7 +77,9 @@ public class YamlItemProvider extends AbstractProvider<Item> implements ItemProv
@Override
public Collection<Item> getAll() {
return itemsMap.values().stream().flatMap(list -> list.stream()).toList();
// Ignore isolated models
return itemsMap.keySet().stream().filter(name -> !isIsolatedModel(name))
.map(name -> itemsMap.getOrDefault(name, List.of())).flatMap(list -> list.stream()).toList();
}
public Collection<Item> getAllFromModel(String modelName) {
@@ -114,7 +118,9 @@ public class YamlItemProvider extends AbstractProvider<Item> implements ItemProv
added.forEach((item, itemDTO) -> {
String name = item.getName();
logger.debug("model {} added item {}", modelName, name);
notifyListenersAboutAddedElement(item);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(item);
}
processChannelLinks(modelName, name, itemDTO);
processMetadata(modelName, name, itemDTO);
});
@@ -138,11 +144,15 @@ public class YamlItemProvider extends AbstractProvider<Item> implements ItemProv
modelItems.remove(oldItem);
modelItems.add(item);
logger.debug("model {} updated item {}", modelName, name);
notifyListenersAboutUpdatedElement(oldItem, item);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutUpdatedElement(oldItem, item);
}
}, () -> {
modelItems.add(item);
logger.debug("model {} added item {}", modelName, name);
notifyListenersAboutAddedElement(item);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(item);
}
});
processChannelLinks(modelName, name, itemDTO);
processMetadata(modelName, name, itemDTO);
@@ -159,7 +169,9 @@ public class YamlItemProvider extends AbstractProvider<Item> implements ItemProv
modelItems.stream().filter(i -> i.getName().equals(name)).findFirst().ifPresentOrElse(oldItem -> {
modelItems.remove(oldItem);
logger.debug("model {} removed item {}", modelName, name);
notifyListenersAboutRemovedElement(oldItem);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutRemovedElement(oldItem);
}
}, () -> logger.debug("model {} item {} not found", modelName, name));
processChannelLinks(modelName, name, null);
processMetadata(modelName, name, null);
@@ -12,6 +12,8 @@
*/
package org.openhab.core.model.yaml.internal.items;
import static org.openhab.core.model.yaml.YamlModelUtils.isIsolatedModel;
import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
@@ -49,8 +51,10 @@ public class YamlMetadataProvider extends AbstractProvider<Metadata> implements
@Override
public Collection<Metadata> 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 -> !isIsolatedModel(name))
.map(name -> metadataMap.getOrDefault(name, Map.of())).flatMap(m -> m.values().stream())
.flatMap(m -> m.values().stream()).toList();
}
public Collection<Metadata> getAllFromModel(String modelName) {
@@ -76,21 +80,27 @@ public class YamlMetadataProvider extends AbstractProvider<Metadata> implements
Metadata oldMd = namespacesMetadataMap.get(namespace);
if (oldMd == null) {
namespacesMetadataMap.put(namespace, md);
logger.debug("notify added metadata {}", md.getUID());
notifyListenersAboutAddedElement(md);
logger.debug("model {} added metadata {}", modelName, namespace);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(md);
}
} else if (!md.getValue().equals(oldMd.getValue())
|| !YamlElementUtils.equalsConfig(md.getConfiguration(), oldMd.getConfiguration())) {
namespacesMetadataMap.put(namespace, md);
logger.debug("notify updated metadata {}", md.getUID());
notifyListenersAboutUpdatedElement(oldMd, md);
logger.debug("model {} updated metadata {}", modelName, namespace);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutUpdatedElement(oldMd, md);
}
}
}
namespaceToBeRemoved.forEach(namespace -> {
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 (!isIsolatedModel(modelName)) {
notifyListenersAboutRemovedElement(md);
}
}
});
if (namespacesMetadataMap.isEmpty()) {
@@ -12,11 +12,13 @@
*/
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;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -26,6 +28,7 @@ import java.util.Set;
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,14 +41,17 @@ import org.openhab.core.items.ItemUtil;
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;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
@@ -56,16 +62,24 @@ import org.osgi.service.component.annotations.Reference;
* @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) {
this.modelRepository = modelRepository;
this.itemProvider = itemProvider;
this.metadataProvider = metadataProvider;
this.channelLinkProvider = channelLinkProvider;
this.configDescriptionRegistry = configDescRegistry;
}
@@ -75,18 +89,23 @@ public class YamlItemFileConverter extends AbstractItemFileGenerator {
}
@Override
public void generateFileFormat(OutputStream out, List<Item> items, Collection<Metadata> metadata,
boolean hideDefaultParameters) {
public void setItemsToBeGenerated(String id, List<Item> items, Collection<Metadata> metadata,
Map<String, String> stateFormatters, boolean hideDefaultParameters) {
List<YamlElement> elements = new ArrayList<>();
items.forEach(item -> {
elements.add(buildItemDTO(item, getChannelLinks(metadata, item.getName()),
getMetadata(metadata, item.getName()), hideDefaultParameters));
getMetadata(metadata, item.getName()), stateFormatters.get(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<Metadata> channelLinks, List<Metadata> metadata,
boolean hideDefaultParameters) {
@Nullable String stateFormatter, boolean hideDefaultParameters) {
YamlItemDTO dto = new YamlItemDTO();
dto.name = item.getName();
@@ -95,9 +114,8 @@ public class YamlItemFileConverter extends AbstractItemFileGenerator {
String defaultPattern = getDefaultStatePattern(item);
if (label != null && !label.isEmpty()) {
dto.label = item.getLabel();
StateDescription stateDescr = item.getStateDescription();
String statePattern = stateDescr == null ? null : stateDescr.getPattern();
String patterToSet = statePattern != null && !statePattern.equals(defaultPattern) ? statePattern : null;
String patterToSet = stateFormatter != null && !stateFormatter.equals(defaultPattern) ? stateFormatter
: null;
dto.format = patterToSet;
patternSet = patterToSet != null;
}
@@ -249,4 +267,44 @@ public class YamlItemFileConverter extends AbstractItemFileGenerator {
}
return parameters;
}
@Override
public String getFileFormatParser() {
return "YAML";
}
@Override
public @Nullable String startParsingFileFormat(String syntax, List<String> errors, List<String> warnings) {
ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes());
return modelRepository.createIsolatedModel(inputStream, errors, warnings);
}
@Override
public Collection<Item> getParsedItems(String modelName) {
return itemProvider.getAllFromModel(modelName);
}
@Override
public Collection<Metadata> getParsedMetadata(String modelName) {
return metadataProvider.getAllFromModel(modelName);
}
@Override
public Map<String, String> getParsedStateFormatters(String modelName) {
Map<String, String> stateFormatters = new HashMap<>();
getParsedMetadata(modelName).forEach(md -> {
if ("stateDescription".equals(md.getUID().getNamespace())) {
Object pattern = md.getConfiguration().get("pattern");
if (pattern instanceof String patternStr && !patternStr.isBlank()) {
stateFormatters.put(md.getUID().getItemName(), patternStr);
}
}
});
return stateFormatters;
}
@Override
public void finishParsingFileFormat(String modelName) {
modelRepository.removeIsolatedModel(modelName);
}
}
@@ -12,6 +12,8 @@
*/
package org.openhab.core.model.yaml.internal.things;
import static org.openhab.core.model.yaml.YamlModelUtils.isIsolatedModel;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
@@ -147,7 +149,13 @@ public class YamlThingProvider extends AbstractProvider<Thing>
@Override
public Collection<Thing> getAll() {
return thingsMap.values().stream().flatMap(list -> list.stream()).toList();
// Ignore isolated models
return thingsMap.keySet().stream().filter(name -> !isIsolatedModel(name))
.map(name -> thingsMap.getOrDefault(name, List.of())).flatMap(list -> list.stream()).toList();
}
public Collection<Thing> getAllFromModel(String modelName) {
return thingsMap.getOrDefault(modelName, List.of());
}
@Override
@@ -173,7 +181,9 @@ public class YamlThingProvider extends AbstractProvider<Thing>
modelThings.addAll(added);
added.forEach(t -> {
logger.debug("model {} added thing {}", modelName, t.getUID());
notifyListenersAboutAddedElement(t);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(t);
}
});
}
@@ -187,11 +197,15 @@ public class YamlThingProvider extends AbstractProvider<Thing>
modelThings.remove(oldThing);
modelThings.add(t);
logger.debug("model {} updated thing {}", modelName, t.getUID());
notifyListenersAboutUpdatedElement(oldThing, t);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutUpdatedElement(oldThing, t);
}
}, () -> {
modelThings.add(t);
logger.debug("model {} added thing {}", modelName, t.getUID());
notifyListenersAboutAddedElement(t);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(t);
}
});
});
}
@@ -204,7 +218,9 @@ public class YamlThingProvider extends AbstractProvider<Thing>
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 (!isIsolatedModel(modelName)) {
notifyListenersAboutRemovedElement(oldThing);
}
}, () -> logger.debug("model {} thing {} not found", modelName, t.getUID()));
});
if (modelThings.isEmpty()) {
@@ -293,7 +309,8 @@ public class YamlThingProvider extends AbstractProvider<Thing>
if (newThing != null) {
logger.debug("Successfully loaded thing \'{}\' during retry", thingUID);
Thing oldThing = null;
for (Collection<Thing> modelThings : thingsMap.values()) {
for (Map.Entry<String, Collection<Thing>> entry : thingsMap.entrySet()) {
Collection<Thing> modelThings = entry.getValue();
oldThing = modelThings.stream().filter(t -> t.getUID().equals(newThing.getUID())).findFirst()
.orElse(null);
if (oldThing != null) {
@@ -301,7 +318,7 @@ public class YamlThingProvider extends AbstractProvider<Thing>
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) && !isIsolatedModel(entry.getKey())) {
notifyListenersAboutUpdatedElement(oldThing, newThing);
}
break;
@@ -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 @@ import org.osgi.service.component.annotations.Reference;
* @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 class YamlThingFileConverter extends AbstractThingFileGenerator {
}
@Override
public synchronized void generateFileFormat(OutputStream out, List<Thing> things, boolean hideDefaultParameters) {
public void setThingsToBeGenerated(String id, List<Thing> things, boolean hideDefaultChannels,
boolean hideDefaultParameters) {
List<YamlElement> 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 @@ public class YamlThingFileConverter extends AbstractThingFileGenerator {
dto.config = config.isEmpty() ? null : config;
Map<String, YamlChannelDTO> channels = new LinkedHashMap<>();
getNonDefaultChannels(thing).forEach(channel -> {
List<Channel> 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 @@ public class YamlThingFileConverter extends AbstractThingFileGenerator {
return dto;
}
@Override
public String getFileFormatParser() {
return "YAML";
}
@Override
public @Nullable String startParsingFileFormat(String syntax, List<String> errors, List<String> warnings) {
ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes());
return modelRepository.createIsolatedModel(inputStream, errors, warnings);
}
@Override
public Collection<Thing> getParsedThings(String modelName) {
return thingProvider.getAllFromModel(modelName);
}
@Override
public Collection<ItemChannelLink> getParsedChannelLinks(String modelName) {
return itemChannelLinkProvider.getAllFromModel(modelName);
}
@Override
public void finishParsingFileFormat(String modelName) {
modelRepository.removeIsolatedModel(modelName);
}
}
@@ -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<Thing> things, boolean hideDefaultParameters);
void setThingsToBeGenerated(String id, List<Thing> 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);
}
@@ -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<String> errors, List<String> 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<Thing> 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<ItemChannelLink> 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);
}
@@ -15,6 +15,7 @@ package org.openhab.core.items.fileconverter;
import java.io.OutputStream;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.items.Item;
@@ -36,13 +37,23 @@ 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 stateFormatters the optional state formatter for each item
* @param hideDefaultParameters true to hide the configuration parameters having the default value
*/
void generateFileFormat(OutputStream out, List<Item> items, Collection<Metadata> metadata,
boolean hideDefaultParameters);
void setItemsToBeGenerated(String id, List<Item> items, Collection<Metadata> metadata,
Map<String, String> stateFormatters, 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);
}
@@ -0,0 +1,80 @@
/*
* 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 java.util.Map;
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<String> errors, List<String> 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<Item> 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<Metadata> getParsedMetadata(String modelName);
/**
* Get the state formatters found when parsing the file format.
*
* @param modelName the model name used for parsing
* @return the state formatters as a Map per item name
*/
Map<String, String> getParsedStateFormatters(String modelName);
/**
* Release the data from a previously started file format parsing.
*
* @param modelName the model name used for parsing
*/
void finishParsingFileFormat(String modelName);
}
@@ -37,7 +37,7 @@ public class GenericMetadataProviderTest {
@Test
public void testAddMetadata() {
GenericMetadataProvider provider = new GenericMetadataProvider();
provider.addMetadata("binding", "item", "value", null);
provider.addMetadata("model", "binding", "item", "value", null);
Collection<Metadata> res = provider.getAll();
assertEquals(1, res.size());
assertEquals("value", res.iterator().next().getValue());
@@ -46,27 +46,27 @@ public class GenericMetadataProviderTest {
@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", "other", "item", "value", null);
provider.addMetadata("model", "binding", "item", "value", null);
provider.addMetadata("model", "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", "other", "item", "value", null);
provider.addMetadata("model", "binding", "item", "value", null);
provider.addMetadata("model", "binding", "other", "value", null);
assertEquals(3, provider.getAll().size());
provider.removeMetadataByNamespace("binding");