diff --git a/bundles/org.openhab.core.io.rest.core/pom.xml b/bundles/org.openhab.core.io.rest.core/pom.xml index 10cf1189e46..4a53460b693 100644 --- a/bundles/org.openhab.core.io.rest.core/pom.xml +++ b/bundles/org.openhab.core.io.rest.core/pom.xml @@ -45,6 +45,11 @@ org.openhab.core.persistence ${project.version} + + org.openhab.core.bundles + org.openhab.core.sitemap + ${project.version} + org.openhab.core.bundles org.openhab.core.config.discovery diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java index 490e4994c76..e30b8f0e9c4 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/fileformat/FileFormatDTO.java @@ -14,6 +14,7 @@ import java.util.List; +import org.openhab.core.sitemap.dto.SitemapDefinitionDTO; import org.openhab.core.thing.dto.ThingDTO; import io.swagger.v3.oas.annotations.media.Schema; @@ -23,6 +24,7 @@ * in a file format (items, things, ...). * * @author Laurent Garnier - Initial contribution + * @author Mark Herwege - Add sitemaps */ @Schema(name = "FileFormat") public class FileFormatDTO { @@ -31,4 +33,6 @@ public class FileFormatDTO { public List items; @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) public List things; + @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + public List sitemaps; } diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java index aad3c45f78e..2a2ba481799 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java @@ -19,6 +19,7 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -66,6 +67,13 @@ import org.openhab.core.items.MetadataRegistry; import org.openhab.core.items.fileconverter.ItemParser; import org.openhab.core.items.fileconverter.ItemSerializer; +import org.openhab.core.sitemap.Sitemap; +import org.openhab.core.sitemap.dto.SitemapDTOMapper; +import org.openhab.core.sitemap.dto.SitemapDefinitionDTO; +import org.openhab.core.sitemap.fileconverter.SitemapParser; +import org.openhab.core.sitemap.fileconverter.SitemapSerializer; +import org.openhab.core.sitemap.registry.SitemapFactory; +import org.openhab.core.sitemap.registry.SitemapRegistry; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -113,13 +121,14 @@ /** * This class acts as a REST resource and provides different methods to generate file format - * for existing items and things. + * for existing items, things and sitemaps. * * This resource is registered with the Jersey servlet. * * @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 + * @author Mark Herwege - Add sitemap DSL */ @Component @JaxrsResource @@ -244,6 +253,27 @@ public class FileFormatResource implements RESTResource { param: my param value """; + private static final String DSL_SITEMAPS_EXAMPLE = """ + sitemap MySitemap label="My Sitemap" { + Frame { + Input item=MyItem label="My Input" + } + } + """; + + private static final String YAML_SITEMAPS_EXAMPLE = """ + version: 1 + sitemaps: + MySitemap: + label: My Sitemap + widgets: + - type: Frame + widgets: + - type: Input + item: MyItem + label: My Input + """; + private static final String GEN_ID_PATTERN = "gen_file_format_%d"; private final Logger logger = LoggerFactory.getLogger(FileFormatResource.class); @@ -257,10 +287,14 @@ public class FileFormatResource implements RESTResource { private final ThingTypeRegistry thingTypeRegistry; private final ChannelTypeRegistry channelTypeRegistry; private final ConfigDescriptionRegistry configDescRegistry; + private final SitemapFactory sitemapFactory; + private final SitemapRegistry sitemapRegistry; private final Map itemSerializers = new ConcurrentHashMap<>(); private final Map itemParsers = new ConcurrentHashMap<>(); private final Map thingSerializers = new ConcurrentHashMap<>(); private final Map thingParsers = new ConcurrentHashMap<>(); + private final Map sitemapSerializers = new ConcurrentHashMap<>(); + private final Map sitemapParsers = new ConcurrentHashMap<>(); private int counter; @@ -274,7 +308,9 @@ public FileFormatResource(// final @Reference Inbox inbox, // final @Reference ThingTypeRegistry thingTypeRegistry, // final @Reference ChannelTypeRegistry channelTypeRegistry, // - final @Reference ConfigDescriptionRegistry configDescRegistry) { + final @Reference ConfigDescriptionRegistry configDescRegistry, // + final @Reference SitemapFactory sitemapFactory, // + final @Reference SitemapRegistry sitemapRegistry) { this.itemBuilderFactory = itemBuilderFactory; this.itemRegistry = itemRegistry; this.metadataRegistry = metadataRegistry; @@ -284,6 +320,8 @@ public FileFormatResource(// this.thingTypeRegistry = thingTypeRegistry; this.channelTypeRegistry = channelTypeRegistry; this.configDescRegistry = configDescRegistry; + this.sitemapFactory = sitemapFactory; + this.sitemapRegistry = sitemapRegistry; } @Deactivate @@ -326,6 +364,24 @@ protected void removeThingParser(ThingParser thingParser) { thingParsers.remove(thingParser.getParserFormat()); } + @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.MULTIPLE) + protected void addSitemapSerializer(SitemapSerializer sitemapSerializer) { + sitemapSerializers.put(sitemapSerializer.getGeneratedFormat(), sitemapSerializer); + } + + protected void removeSitemapSerializer(SitemapSerializer sitemapSerializer) { + sitemapSerializers.remove(sitemapSerializer.getGeneratedFormat()); + } + + @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.MULTIPLE) + protected void addSitemapParser(SitemapParser sitemapParser) { + sitemapParsers.put(sitemapParser.getParserFormat(), sitemapParser); + } + + protected void removeSitemapParser(SitemapParser sitemapParser) { + sitemapParsers.remove(sitemapParser.getParserFormat()); + } + @POST @RolesAllowed({ Role.ADMIN }) @Path("/items") @@ -416,16 +472,71 @@ public Response createFileFormatForThings(final @Context HttpHeaders httpHeaders return Response.ok(new String(outputStream.toByteArray())).build(); } + @POST + @RolesAllowed({ Role.ADMIN }) + @Path("/sitemaps") + @Consumes(MediaType.APPLICATION_JSON) + @Produces({ "text/vnd.openhab.dsl.sitemap", "application/yaml" }) + @Operation(operationId = "createFileFormatForSitemaps", summary = "Create file format for a list of sitemaps in registry.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK", content = { + @Content(mediaType = "text/vnd.openhab.dsl.sitemap", schema = @Schema(example = DSL_SITEMAPS_EXAMPLE)), + @Content(mediaType = "application/yaml", schema = @Schema(example = YAML_SITEMAPS_EXAMPLE)) }), + @ApiResponse(responseCode = "400", description = "Payload invalid."), + @ApiResponse(responseCode = "404", description = "One or more sitemaps not found in registry."), + @ApiResponse(responseCode = "415", description = "Unsupported media type.") }) + public Response createFileFormatForSitemaps(final @Context HttpHeaders httpHeaders, + @Parameter(description = "Array of Sitemap names. If empty or omitted, return all Sitemaps from the Registry.") @Nullable List sitemapNames) { + String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); + logger.debug("createFileFormatForSitemaps: mediaType = {}, sitemapNames = {}", acceptHeader, sitemapNames); + SitemapSerializer serializer = getSitemapSerializer(acceptHeader); + if (serializer == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } + + if (acceptHeader.equals("text/vnd.openhab.dsl.sitemap") && (sitemapNames == null || sitemapNames.size() != 1)) { + // DSL format only supports one sitemap at a time + return Response.status(Response.Status.BAD_REQUEST) + .entity("For media type 'text/vnd.openhab.dsl.sitemap', exactly one sitemap name must be provided!") + .build(); + } + + List sitemaps; + if (sitemapNames == null || sitemapNames.isEmpty()) { + sitemaps = sitemapRegistry.getAll().stream().sorted(Comparator.comparing(Sitemap::getName)).toList(); + } else { + sitemaps = new ArrayList<>(); + for (String sitemapName : sitemapNames) { + Sitemap sitemap = sitemapRegistry.get(sitemapName); + if (sitemap == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("Sitemap with name '" + sitemapName + "' not found in the sitemaps registry!") + .build(); + } + sitemaps.add(sitemap); + } + } + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + String genId = newIdForSerialization(); + serializer.setSitemapsToBeSerialized(genId, sitemaps); + serializer.generateFormat(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" }) + @Produces({ "text/vnd.openhab.dsl.thing", "text/vnd.openhab.dsl.item", "text/vnd.openhab.dsl.sitemap", + "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 = "text/vnd.openhab.dsl.sitemap", schema = @Schema(example = DSL_SITEMAPS_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.") }) @@ -441,13 +552,15 @@ public Response create(final @Context HttpHeaders httpHeaders, List items = new ArrayList<>(); List metadata = new ArrayList<>(); Map stateFormatters = new HashMap<>(); + List sitemaps = new ArrayList<>(); List errors = new ArrayList<>(); - if (!convertFromFileFormatDTO(data, things, items, metadata, stateFormatters, errors)) { + if (!convertFromFileFormatDTO(data, things, items, metadata, stateFormatters, sitemaps, errors)) { return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); } ThingSerializer thingSerializer = getThingSerializer(acceptHeader); ItemSerializer itemSerializer = getItemSerializer(acceptHeader); + SitemapSerializer sitemapSerializer = getSitemapSerializer(acceptHeader); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); String genId = newIdForSerialization(); switch (acceptHeader) { @@ -472,6 +585,21 @@ public Response create(final @Context HttpHeaders httpHeaders, stateFormatters, hideDefaultParameters); itemSerializer.generateFormat(genId, outputStream); break; + case "text/vnd.openhab.dsl.sitemap": + if (sitemapSerializer == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } else if (sitemaps.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST).entity("No sitemaps loaded from input").build(); + } else if (sitemaps.size() != 1) { + // DSL format only supports one sitemap at a time + return Response.status(Response.Status.BAD_REQUEST).entity( + "For media type 'text/vnd.openhab.dsl.sitemap', the JSON payload must contain exactly one sitemap definition!") + .build(); + } + sitemapSerializer.setSitemapsToBeSerialized(genId, sitemaps); + sitemapSerializer.generateFormat(genId, outputStream); + break; case "application/yaml": if (thingSerializer != null) { thingSerializer.setThingsToBeSerialized(genId, things, hideDefaultChannels, hideDefaultParameters); @@ -480,10 +608,15 @@ public Response create(final @Context HttpHeaders httpHeaders, itemSerializer.setItemsToBeSerialized(genId, items, hideChannelLinksAndMetadata ? List.of() : metadata, stateFormatters, hideDefaultParameters); } + if (sitemapSerializer != null) { + sitemapSerializer.setSitemapsToBeSerialized(genId, sitemaps); + } if (thingSerializer != null) { thingSerializer.generateFormat(genId, outputStream); } else if (itemSerializer != null) { itemSerializer.generateFormat(genId, outputStream); + } else if (sitemapSerializer != null) { + sitemapSerializer.generateFormat(genId, outputStream); } break; default: @@ -496,7 +629,8 @@ public Response create(final @Context HttpHeaders httpHeaders, @POST @RolesAllowed({ Role.ADMIN }) @Path("/parse") - @Consumes({ "text/vnd.openhab.dsl.thing", "text/vnd.openhab.dsl.item", "application/yaml" }) + @Consumes({ "text/vnd.openhab.dsl.thing", "text/vnd.openhab.dsl.item", "text/vnd.openhab.dsl.sitemap", + "application/yaml" }) @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "parse", summary = "Parse file format.", security = { @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { @@ -507,6 +641,7 @@ 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 = "text/vnd.openhab.dsl.sitemap", schema = @Schema(example = DSL_SITEMAPS_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); @@ -514,6 +649,7 @@ public Response parse(final @Context HttpHeaders httpHeaders, // First parse the input Collection things = List.of(); Collection items = List.of(); + Collection sitemaps = List.of(); Collection metadata = List.of(); Collection channelLinks = List.of(); Map stateFormatters = Map.of(); @@ -521,8 +657,10 @@ public Response parse(final @Context HttpHeaders httpHeaders, List warnings = new ArrayList<>(); ThingParser thingParser = getThingParser(contentTypeHeader); ItemParser itemParser = getItemParser(contentTypeHeader); + SitemapParser sitemapParser = getSitemapParser(contentTypeHeader); String modelName = null; String modelName2 = null; + String modelName3 = null; switch (contentTypeHeader) { case "text/vnd.openhab.dsl.thing": if (thingParser == null) { @@ -561,6 +699,21 @@ public Response parse(final @Context HttpHeaders httpHeaders, channelLinks = thingParser.getParsedChannelLinks(modelName2); } break; + case "text/vnd.openhab.dsl.sitemap": + if (sitemapParser == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported content type '" + contentTypeHeader + "'!").build(); + } + modelName3 = sitemapParser.startParsingFormat(input, errors, warnings); + if (modelName3 == null) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build(); + } + sitemaps = sitemapParser.getParsedObjects(modelName3); + if (sitemaps.isEmpty()) { + sitemapParser.finishParsingFormat(modelName3); + return Response.status(Response.Status.BAD_REQUEST).entity("No sitemap loaded from input").build(); + } + break; case "application/yaml": if (thingParser != null) { modelName = thingParser.startParsingFormat(input, errors, warnings); @@ -584,19 +737,35 @@ public Response parse(final @Context HttpHeaders httpHeaders, metadata = itemParser.getParsedMetadata(modelNameToUse); stateFormatters = itemParser.getParsedStateFormatters(modelNameToUse); } + if (sitemapParser != null) { + // Avoid parsing the input a second time + if (modelName == null && modelName2 == null) { + modelName3 = sitemapParser.startParsingFormat(input, errors, warnings); + if (modelName3 == null) { + return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)) + .build(); + } + } + String modelNameToUse = modelName != null ? modelName + : (modelName2 != null ? modelName2 : Objects.requireNonNull(modelName3)); + sitemaps = sitemapParser.getParsedObjects(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); + sitemaps, warnings); if (modelName != null && thingParser != null) { thingParser.finishParsingFormat(modelName); } if (modelName2 != null && itemParser != null) { itemParser.finishParsingFormat(modelName2); } + if (modelName3 != null && sitemapParser != null) { + sitemapParser.finishParsingFormat(modelName3); + } return Response.ok(result).build(); } @@ -764,6 +933,14 @@ private Thing simulateThing(DiscoveryResult result, ThingType thingType) { }; } + private @Nullable SitemapSerializer getSitemapSerializer(String mediaType) { + return switch (mediaType) { + case "text/vnd.openhab.dsl.sitemap" -> sitemapSerializers.get("DSL"); + case "application/yaml" -> sitemapSerializers.get("YAML"); + default -> null; + }; + } + private @Nullable ItemParser getItemParser(String contentType) { return switch (contentType) { case "text/vnd.openhab.dsl.item" -> itemParsers.get("DSL"); @@ -781,6 +958,14 @@ private Thing simulateThing(DiscoveryResult result, ThingType thingType) { }; } + private @Nullable SitemapParser getSitemapParser(String contentType) { + return switch (contentType) { + case "text/vnd.openhab.dsl.sitemap" -> sitemapParsers.get("DSL"); + case "application/yaml" -> sitemapParsers.get("YAML"); + default -> null; + }; + } + private List getThingsOrDiscoveryResult(List thingUIDs) { return thingUIDs.stream().distinct().map(uid -> { ThingUID thingUID = new ThingUID(uid); @@ -803,7 +988,7 @@ private List getThingsOrDiscoveryResult(List thingUIDs) { } private boolean convertFromFileFormatDTO(FileFormatDTO data, List things, List items, - List metadata, Map stateFormatters, List errors) { + List metadata, Map stateFormatters, List sitemaps, List errors) { boolean ok = true; if (data.things != null) { for (ThingDTO thingData : data.things) { @@ -886,12 +1071,26 @@ private boolean convertFromFileFormatDTO(FileFormatDTO data, List things, } } } + if (data.sitemaps != null) { + for (SitemapDefinitionDTO sitemapData : data.sitemaps) { + String name = sitemapData.name; + try { + Sitemap sitemap = SitemapDTOMapper.map(sitemapData, sitemapFactory); + sitemaps.add(sitemap); + } catch (IllegalArgumentException e) { + errors.add("Invalid sitemap data" + (name != null ? " for sitemap '" + name + "'" : "") + ": " + + e.getMessage()); + ok = false; + continue; + } + } + } return ok; } private ExtendedFileFormatDTO convertToFileFormatDTO(Collection things, Collection items, Collection metadata, Map stateFormatters, - Collection channelLinks, List warnings) { + Collection channelLinks, Collection sitemaps, List warnings) { ExtendedFileFormatDTO dto = new ExtendedFileFormatDTO(); dto.warnings = warnings.isEmpty() ? null : warnings; if (!things.isEmpty()) { @@ -938,6 +1137,12 @@ private ExtendedFileFormatDTO convertToFileFormatDTO(Collection things, C FileFormatItemDTOMapper.map(item, metadata, stateFormatters.get(item.getName()), channelLinks)); }); } + if (!sitemaps.isEmpty()) { + dto.sitemaps = new ArrayList<>(); + sitemaps.forEach(sitemap -> { + dto.sitemaps.add(SitemapDTOMapper.map(sitemap)); + }); + } return dto; } diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/EnrichedSitemapDefinitionDTO.java b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/EnrichedSitemapDefinitionDTO.java new file mode 100644 index 00000000000..5aade9a4a05 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/EnrichedSitemapDefinitionDTO.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.internal; + +import org.openhab.core.sitemap.dto.SitemapDefinitionDTO; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * This is a data transfer object that is used to serialize sitemaps to represent or edit in the UI. + * + * @author Mark Herwege - Initial contribution + */ +@Schema(name = "EnrichedSitemapDefinition") +public class EnrichedSitemapDefinitionDTO extends SitemapDefinitionDTO { + + public boolean editable; + + public EnrichedSitemapDefinitionDTO(SitemapDefinitionDTO dto) { + this.name = dto.name; + this.label = dto.label; + this.icon = dto.icon; + this.widgets = dto.widgets; + } +} diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapDTO.java b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapDTO.java index 7ff093ea03a..36f05375a9e 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapDTO.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapDTO.java @@ -12,20 +12,19 @@ */ package org.openhab.core.io.rest.sitemap.internal; +import org.openhab.core.sitemap.dto.AbstractSitemapDTO; + import io.swagger.v3.oas.annotations.media.Schema; /** * This is a data transfer object that is used to serialize sitemaps. - * + * * @author Kai Kreuzer - Initial contribution * @author Chris Jackson - Initial contribution + * @author Mark Herwege - Moved to abstract class and extend */ @Schema(name = "Sitemap") -public class SitemapDTO { - - public String name; - public String icon; - public String label; +public class SitemapDTO extends AbstractSitemapDTO { public String link; diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapResource.java b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapResource.java index 572663d7b39..34cdf711444 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapResource.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapResource.java @@ -35,10 +35,12 @@ import javax.annotation.security.RolesAllowed; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import javax.ws.rs.DefaultValue; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; +import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; @@ -79,7 +81,6 @@ import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.HSBType; import org.openhab.core.sitemap.Button; -import org.openhab.core.sitemap.ButtonDefinition; import org.openhab.core.sitemap.Buttongrid; import org.openhab.core.sitemap.Chart; import org.openhab.core.sitemap.Colortemperaturepicker; @@ -88,7 +89,6 @@ import org.openhab.core.sitemap.Image; import org.openhab.core.sitemap.Input; import org.openhab.core.sitemap.LinkableWidget; -import org.openhab.core.sitemap.Mapping; import org.openhab.core.sitemap.Mapview; import org.openhab.core.sitemap.Parent; import org.openhab.core.sitemap.Rule; @@ -100,8 +100,13 @@ import org.openhab.core.sitemap.Video; import org.openhab.core.sitemap.Webview; import org.openhab.core.sitemap.Widget; +import org.openhab.core.sitemap.dto.MappingDTO; +import org.openhab.core.sitemap.dto.SitemapDTOMapper; +import org.openhab.core.sitemap.dto.SitemapDefinitionDTO; +import org.openhab.core.sitemap.registry.SitemapFactory; import org.openhab.core.sitemap.registry.SitemapRegistry; import org.openhab.core.types.State; +import org.openhab.core.ui.components.ManagedSitemapProvider; import org.openhab.core.ui.items.ItemUIRegistry; import org.openhab.core.ui.items.ItemUIRegistry.WidgetLabelSource; import org.openhab.core.util.ColorUtil; @@ -123,6 +128,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; /** @@ -145,6 +151,7 @@ * @author Laurent Garnier - Added support for Buttongrid as container for Button elements * @author Laurent Garnier - Added support for new sitemap element Colortemperaturepicker * @author Mark Herwege - Implement sitemap registry, remove Guava dependency + * @author Mark Herwege - Implement sitemap definition endpoints */ @Component(service = { RESTResource.class, EventSubscriber.class }) @JaxrsResource @@ -166,6 +173,9 @@ public class SitemapResource private static final long TIMEOUT_IN_MS = 30000; + private final ScheduledExecutorService scheduler = ThreadPoolManager + .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON); + private SseBroadcaster broadcaster; @Context @@ -185,28 +195,31 @@ public class SitemapResource Sse sse; private final ItemUIRegistry itemUIRegistry; + private final SitemapFactory sitemapFactory; private final SitemapRegistry sitemapRegistry; + private final ManagedSitemapProvider managedSitemapProvider; private final SitemapSubscriptionService subscriptions; private final LocaleService localeService; private final TimeZoneProvider timeZoneProvider; private final WeakValueConcurrentHashMap knownSubscriptions = new WeakValueConcurrentHashMap<>(); - private final ScheduledExecutorService scheduler = ThreadPoolManager - .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON); - private @Nullable ScheduledFuture cleanSubscriptionsJob; private Set stateChangeListeners = new CopyOnWriteArraySet<>(); @Activate public SitemapResource( // final @Reference ItemUIRegistry itemUIRegistry, // + final @Reference SitemapFactory sitemapFactory, // final @Reference SitemapRegistry sitemapRegistry, // + final @Reference ManagedSitemapProvider managedSitemapProvider, // final @Reference LocaleService localeService, // final @Reference TimeZoneProvider timeZoneProvider, // final @Reference SitemapSubscriptionService subscriptions) { this.itemUIRegistry = itemUIRegistry; + this.sitemapFactory = sitemapFactory; this.sitemapRegistry = sitemapRegistry; + this.managedSitemapProvider = managedSitemapProvider; this.localeService = localeService; this.timeZoneProvider = timeZoneProvider; this.subscriptions = subscriptions; @@ -238,6 +251,121 @@ protected void deactivate() { broadcaster.close(); } + @GET + @Path("/*/definition") + @Produces(MediaType.APPLICATION_JSON) + @Operation(operationId = "getSitemapDefinitions", summary = "Get all available sitemap definitions.", responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = EnrichedSitemapDefinitionDTO.class)))) }) + public Response getSitemapsDefinition() { + logger.debug("Received HTTP GET request from IP {} at '{}'", request.getRemoteAddr(), uriInfo.getPath()); + Collection responseObject = sitemapRegistry.getAll().stream() + .map(SitemapDTOMapper::map).map(this::setIsEditable).toList(); + return Response.ok(responseObject).build(); + } + + @GET + @Path("/{sitemapname: [a-zA-Z_0-9]+}/definition") + @Produces(MediaType.APPLICATION_JSON) + @Operation(operationId = "getSitemapDefinitionByName", summary = "Get sitemap definition by name.", responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = EnrichedSitemapDefinitionDTO.class))), + @ApiResponse(responseCode = "404", description = "Sitemap not found") }) + public Response getSitemapDefinition(@Context HttpHeaders headers, + @PathParam("sitemapname") @Parameter(description = "sitemap name") String sitemapname) { + logger.debug("Received HTTP GET request from IP {} at '{}'.", request.getRemoteAddr(), uriInfo.getPath()); + Sitemap sitemap = getSitemap(sitemapname); + if (sitemap == null) { + return Response.status(Status.NOT_FOUND).build(); + } + EnrichedSitemapDefinitionDTO responseObject = setIsEditable(SitemapDTOMapper.map(sitemap)); + return Response.ok(responseObject).build(); + } + + private EnrichedSitemapDefinitionDTO setIsEditable(SitemapDefinitionDTO dto) { + EnrichedSitemapDefinitionDTO enrichedDto = new EnrichedSitemapDefinitionDTO(dto); + enrichedDto.editable = managedSitemapProvider.get(dto.name) != null; + return enrichedDto; + } + + /** + * Create or Update a sitemap. + * + * @param sitemapname the sitemap name + * @param sitemap data. + * @return Response configured to represent the sitemap depending on the status + */ + @PUT + @RolesAllowed({ Role.ADMIN }) + @Path("/{sitemapname: [a-zA-Z_0-9]+}") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(operationId = "addOrUpdateSitemapInRegistry", summary = "Adds a new sitemap to the registry or updates the existing sitemap.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "Sitemap updated.", content = @Content(schema = @Schema(implementation = SitemapDefinitionDTO.class))), + @ApiResponse(responseCode = "201", description = "Sitemap created.", content = @Content(schema = @Schema(implementation = SitemapDefinitionDTO.class))), + @ApiResponse(responseCode = "400", description = "Payload invalid."), + @ApiResponse(responseCode = "405", description = "Sitemap not editable.") }) + public Response createOrUpdateSitemap(final @Context HttpHeaders httpHeaders, + @PathParam("sitemapname") @Parameter(description = "sitemap name") String sitemapName, + @Parameter(description = "sitemap data", required = true) @Nullable SitemapDefinitionDTO sitemapDTO) { + // If we didn't get a sitemap, then return! + if (sitemapDTO == null) { + return JSONResponse.createErrorResponse(Status.BAD_REQUEST, "Sitemap payload is required."); + } else if (sitemapDTO.name == null) { + logger.warn("Received HTTP PUT request at '{}' with a missing sitemap name in the payload.", + uriInfo.getPath()); + return JSONResponse.createErrorResponse(Status.BAD_REQUEST, "Sitemap name in payload must not be null."); + } else if (!sitemapName.equals(sitemapDTO.name)) { + logger.warn( + "Received HTTP PUT request at '{}' with a sitemap name '{}' that does not match the one in the url.", + uriInfo.getPath(), sitemapDTO.name); + return JSONResponse.createErrorResponse(Status.BAD_REQUEST, + "Sitemap name in payload must match sitemap name in URL."); + } + + try { + Sitemap sitemap = SitemapDTOMapper.map(sitemapDTO, sitemapFactory); + + // Save the sitemap + if (getSitemap(sitemapName) == null) { + // sitemap does not yet exist, create it + managedSitemapProvider.add(sitemap); + return JSONResponse.createResponse(Status.CREATED, sitemapDTO, null); + } else if (managedSitemapProvider.get(sitemapName) != null) { + // sitemap already exists as a managed sitemap, update it + managedSitemapProvider.update(sitemap); + return JSONResponse.createResponse(Status.OK, SitemapDTOMapper.map(sitemap), null); + } else { + // Sitemap exists but cannot be updated + logger.warn("Cannot update existing sitemap '{}', because is not managed.", sitemapName); + return JSONResponse.createErrorResponse(Status.METHOD_NOT_ALLOWED, + "Cannot update unmanaged sitemap " + sitemapName); + } + } catch (IllegalArgumentException e) { + logger.warn("Received HTTP PUT request at '{}' with an invalid sitemap: {}", uriInfo.getPath(), + e.getMessage()); + return JSONResponse.createErrorResponse(Status.BAD_REQUEST, "Sitemap invalid: " + e.getMessage()); + } + } + + @DELETE + @RolesAllowed({ Role.ADMIN }) + @Path("/{sitemapname: [a-zA-Z_0-9]+}") + @Operation(operationId = "removeSitemapFromRegistry", summary = "Removes a sitemap from the registry.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Sitemap not found."), + @ApiResponse(responseCode = "405", description = "Sitemap not editable.") }) + public Response removeSitemap( + @PathParam("sitemapname") @Parameter(description = "sitemap name") String sitemapName) { + if (getSitemap(sitemapName) == null) { + return Response.status(Status.NOT_FOUND).build(); + } + if (managedSitemapProvider.remove(sitemapName) == null) { + return JSONResponse.createErrorResponse(Status.METHOD_NOT_ALLOWED, + "Cannot delete unmanaged sitemap " + sitemapName); + } + return Response.ok(null, MediaType.TEXT_PLAIN).build(); + } + @GET @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "getSitemaps", summary = "Get all available sitemaps.", responses = { @@ -256,11 +384,9 @@ public Response getSitemaps() { public Response getSitemapData(@Context HttpHeaders headers, @HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) @Parameter(description = "language") @Nullable String language, @PathParam("sitemapname") @Parameter(description = "sitemap name") String sitemapname, - @QueryParam("type") String type, @QueryParam("jsoncallback") @DefaultValue("callback") String callback, @QueryParam("includeHidden") @Parameter(description = "include hidden widgets") boolean includeHiddenWidgets) { final Locale locale = localeService.getLocale(language); - logger.debug("Received HTTP GET request from IP {} at '{}' for media type '{}'.", request.getRemoteAddr(), - uriInfo.getPath(), type); + logger.debug("Received HTTP GET request from IP {} at '{}'.", request.getRemoteAddr(), uriInfo.getPath()); URI uri = uriInfo.getBaseUriBuilder().build(); SitemapDTO responseObject = getSitemapBean(sitemapname, uri, locale, includeHiddenWidgets, false); return Response.ok(responseObject).build(); @@ -620,22 +746,22 @@ private PageDTO createPageBean(String sitemapName, @Nullable String title, @Null } } if (widget instanceof Switch switchWidget) { - for (Mapping mapping : switchWidget.getMappings()) { + bean.mappings = switchWidget.getMappings().stream().map(mapping -> { MappingDTO mappingBean = new MappingDTO(); mappingBean.command = mapping.getCmd(); mappingBean.releaseCommand = mapping.getReleaseCmd(); mappingBean.label = mapping.getLabel(); mappingBean.icon = mapping.getIcon(); - bean.mappings.add(mappingBean); - } + return mappingBean; + }).toList(); } if (widget instanceof Selection selectionWidget) { - for (Mapping mapping : selectionWidget.getMappings()) { + bean.mappings = selectionWidget.getMappings().stream().map(mapping -> { MappingDTO mappingBean = new MappingDTO(); mappingBean.command = mapping.getCmd(); mappingBean.label = mapping.getLabel(); - bean.mappings.add(mappingBean); - } + return mappingBean; + }).toList(); } if (widget instanceof Input inputWidget) { bean.inputHint = inputWidget.getInputHint(); @@ -692,15 +818,15 @@ private PageDTO createPageBean(String sitemapName, @Nullable String title, @Null bean.maxValue = colortemperaturepickerWidget.getMaxValue(); } if (widget instanceof Buttongrid buttonGridWidget) { - for (ButtonDefinition button : buttonGridWidget.getButtons()) { + bean.mappings = buttonGridWidget.getButtons().stream().map(button -> { MappingDTO mappingBean = new MappingDTO(); mappingBean.row = button.getRow(); mappingBean.column = button.getColumn(); mappingBean.command = button.getCmd(); mappingBean.label = button.getLabel(); mappingBean.icon = button.getIcon(); - bean.mappings.add(mappingBean); - } + return mappingBean; + }).toList(); } if (widget instanceof Button buttonWidget) { // Get the icon from the widget only @@ -778,8 +904,7 @@ private boolean blockUntilChangeOccurs(String sitemapname, @Nullable String page * This method only returns when a change has occurred to any item on the * page to display or if the timeout is reached * - * @param widgets - * the widgets of the page to observe + * @param widgets the widgets of the page to observe * @return true if the timeout is reached */ private boolean waitForChanges(List widgets) { @@ -808,8 +933,7 @@ private boolean waitForChanges(List widgets) { /** * Collects all items that are represented by a given list of widgets * - * @param widgets - * the widget list to get the items for added to all bundles containing REST resources + * @param widgets the widget list to get the items for added to all bundles containing REST resources * @return all items that are represented by the list of widgets */ private Set getAllItems(List widgets) { diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/WidgetDTO.java b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/WidgetDTO.java index a66fd8e2205..bea0124c830 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/WidgetDTO.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/WidgetDTO.java @@ -12,11 +12,11 @@ */ package org.openhab.core.io.rest.sitemap.internal; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import org.openhab.core.io.rest.core.item.EnrichedItemDTO; +import org.openhab.core.sitemap.dto.AbstractWidgetDTO; import io.swagger.v3.oas.annotations.media.Schema; @@ -31,24 +31,15 @@ * @author Danny Baumann - New field labelSource * @author Laurent Garnier - Remove field columns * @author Laurent Garnier - New fields row, column, command, releaseCommand and stateless for Button element + * @author Mark Herwege - Extends abstract widget DTO */ @Schema(name = "Widget") -public class WidgetDTO { +public class WidgetDTO extends AbstractWidgetDTO { public String widgetId; - public String type; - public String name; public boolean visibility; - - public String label; public String labelSource; - public String icon; - /** - * staticIcon is a boolean indicating if the widget state must be ignored when requesting the icon. - * It is set to true when the widget has either the staticIcon property set or the icon property set - * with conditional rules. - */ - public Boolean staticIcon; + public String labelcolor; public String valuecolor; public String iconcolor; @@ -56,29 +47,6 @@ public class WidgetDTO { public String pattern; public String unit; - // widget-specific attributes - public final List mappings = new ArrayList<>(); - public Boolean switchSupport; - public Boolean releaseOnly; - public Integer refresh; - public Integer height; - public BigDecimal minValue; - public BigDecimal maxValue; - public BigDecimal step; - public String inputHint; - public String url; - public String encoding; - public String service; - public String period; - public String yAxisDecimalPattern; - public String interpolation; - public Boolean legend; - public Boolean forceAsItem; - public Integer row; - public Integer column; - public String command; - public String releaseCommand; - public Boolean stateless; public String state; public EnrichedItemDTO item; diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/test/java/org/openhab/core/io/rest/sitemap/internal/SitemapResourceTest.java b/bundles/org.openhab.core.io.rest.sitemap/src/test/java/org/openhab/core/io/rest/sitemap/internal/SitemapResourceTest.java index 9d82d63e122..1e65e7f8d2c 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/test/java/org/openhab/core/io/rest/sitemap/internal/SitemapResourceTest.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/test/java/org/openhab/core/io/rest/sitemap/internal/SitemapResourceTest.java @@ -15,6 +15,7 @@ import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.collection.IsCollectionWithSize.hasSize; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import java.util.ArrayList; @@ -52,10 +53,14 @@ import org.openhab.core.sitemap.Rule; import org.openhab.core.sitemap.Sitemap; import org.openhab.core.sitemap.Widget; +import org.openhab.core.sitemap.dto.SitemapDefinitionDTO; +import org.openhab.core.sitemap.internal.SitemapImpl; +import org.openhab.core.sitemap.registry.SitemapFactory; import org.openhab.core.sitemap.registry.SitemapRegistry; import org.openhab.core.test.java.JavaTest; import org.openhab.core.types.Command; import org.openhab.core.types.State; +import org.openhab.core.ui.components.ManagedSitemapProvider; import org.openhab.core.ui.items.ItemUIRegistry; import org.openhab.core.ui.items.ItemUIRegistry.WidgetLabelSource; import org.osgi.framework.BundleContext; @@ -116,7 +121,9 @@ public class SitemapResourceTest extends JavaTest { private @Mock @NonNullByDefault({}) TimeZoneProvider timeZoneProviderMock; private @Mock @NonNullByDefault({}) LocaleService localeServiceMock; private @Mock @NonNullByDefault({}) HttpServletRequest requestMock; + private @Mock @NonNullByDefault({}) SitemapFactory sitemapFactory; private @Mock @NonNullByDefault({}) SitemapRegistry sitemapRegistryMock; + private @Mock @NonNullByDefault({}) ManagedSitemapProvider managedSitemapProviderMock; private @Mock @NonNullByDefault({}) UriInfo uriInfoMock; private @Mock @NonNullByDefault({}) BundleContext bundleContextMock; @@ -127,8 +134,8 @@ public void setup() throws Exception { subscriptions = new SitemapSubscriptionService(Collections.emptyMap(), itemUIRegistryMock, sitemapRegistryMock, timeZoneProviderMock, bundleContextMock); - sitemapResource = new SitemapResource(itemUIRegistryMock, sitemapRegistryMock, localeServiceMock, - timeZoneProviderMock, subscriptions); + sitemapResource = new SitemapResource(itemUIRegistryMock, sitemapFactory, sitemapRegistryMock, + managedSitemapProviderMock, localeServiceMock, timeZoneProviderMock, subscriptions); when(uriInfoMock.getAbsolutePathBuilder()).thenReturn(UriBuilder.fromPath(SITEMAP_PATH)); when(uriInfoMock.getBaseUriBuilder()).thenReturn(UriBuilder.fromPath(SITEMAP_PATH)); @@ -156,6 +163,103 @@ public void setup() throws Exception { when(headersMock.getRequestHeader(HTTP_HEADER_X_ATMOSPHERE_TRANSPORT)).thenReturn(null); } + @Test + public void whenGetSitemapsDefinition_shouldSetEditableFlag() { + // sitemapRegistryMock.getAll() already returns Set.of(defaultSitemapMock) via configureSitemapRegistryMock + // This test will have that sitemap be a managed sitemap + when(managedSitemapProviderMock.get(SITEMAP_NAME)).thenReturn(new SitemapImpl(SITEMAP_NAME)); + + Response resp = sitemapResource.getSitemapsDefinition(); + assertThat(resp.getStatus(), is(200)); + + @SuppressWarnings("unchecked") + List body = (List) resp.getEntity(); + assertThat(body, hasSize(1)); + assertThat(body.get(0).name, is(SITEMAP_NAME)); + assertThat(body.get(0).editable, is(true)); + } + + @Test + public void whenGetSitemapDefinition_notFound_shouldReturn404() { + when(sitemapRegistryMock.get("noexist")).thenReturn(null); + Response resp = sitemapResource.getSitemapDefinition(headersMock, "noexist"); + assertThat(resp.getStatus(), is(Response.Status.NOT_FOUND.getStatusCode())); + } + + @Test + public void whenCreateOrUpdateSitemap_nullBody_shouldReturnBadRequest() { + Object resp = sitemapResource.createOrUpdateSitemap(headersMock, "any", null); + assertThat(((Response) resp).getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode())); + } + + @Test + public void whenCreateOrUpdateSitemap_nameMismatch_shouldReturnBadRequest() { + SitemapDefinitionDTO dto = new SitemapDefinitionDTO(); + dto.name = "other"; + Object resp = sitemapResource.createOrUpdateSitemap(headersMock, "pathName", dto); + assertThat(((Response) resp).getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode())); + } + + @Test + public void whenCreateOrUpdateSitemap_createNew_shouldAddManagedSitemap() { + SitemapDefinitionDTO dto = new SitemapDefinitionDTO(); + dto.name = "s1"; + + when(sitemapRegistryMock.get("s1")).thenReturn(null); + when(sitemapFactory.createSitemap("s1")).thenReturn(new SitemapImpl("s1")); + + Object respObj = sitemapResource.createOrUpdateSitemap(headersMock, "s1", dto); + Response resp = (Response) respObj; + assertThat(resp.getStatus(), is(Response.Status.CREATED.getStatusCode())); + + verify(managedSitemapProviderMock, times(1)).add(any()); + } + + @Test + public void whenCreateOrUpdateSitemap_updateManaged_shouldUpdateManagedSitemap() { + SitemapDefinitionDTO dto = new SitemapDefinitionDTO(); + dto.name = "s2"; + + when(sitemapRegistryMock.get("s2")).thenReturn(mock(Sitemap.class)); + when(managedSitemapProviderMock.get("s2")).thenReturn(mock(Sitemap.class)); + when(sitemapFactory.createSitemap("s2")).thenReturn(new SitemapImpl("s2")); + + Object respObj = sitemapResource.createOrUpdateSitemap(headersMock, "s2", dto); + Response resp = (Response) respObj; + assertThat(resp.getStatus(), is(Response.Status.OK.getStatusCode())); + + verify(managedSitemapProviderMock, times(1)).update(any()); + } + + @Test + public void whenRemoveSitemap_notFound_shouldReturn404() { + when(sitemapRegistryMock.get("xyz")).thenReturn(null); + Response resp = sitemapResource.removeSitemap("xyz"); + assertThat(resp.getStatus(), is(Response.Status.NOT_FOUND.getStatusCode())); + } + + @Test + public void whenRemoveSitemap_notManaged_shouldReturnMethodNotAllowed() { + Sitemap sitemap = mock(Sitemap.class); + when(sitemapRegistryMock.get("sdel")).thenReturn(sitemap); + when(managedSitemapProviderMock.remove("sdel")).thenReturn(null); + + Response resp = sitemapResource.removeSitemap("sdel"); + assertThat(resp.getStatus(), is(Response.Status.METHOD_NOT_ALLOWED.getStatusCode())); + verify(managedSitemapProviderMock, times(1)).remove("sdel"); + } + + @Test + public void whenRemoveSitemap_managed_shouldReturnOk() { + Sitemap sitemap = mock(Sitemap.class); + when(sitemapRegistryMock.get("sdel2")).thenReturn(sitemap); + when(managedSitemapProviderMock.remove("sdel2")).thenReturn(sitemap); + + Response resp = sitemapResource.removeSitemap("sdel2"); + assertThat(resp.getStatus(), is(Response.Status.OK.getStatusCode())); + verify(managedSitemapProviderMock, times(1)).remove("sdel2"); + } + @Test public void whenSitemapsAreProvidedShouldReturnSitemapBeans() { Response sitemaps = sitemapResource.getSitemaps(); diff --git a/bundles/org.openhab.core.model.sitemap/bnd.bnd b/bundles/org.openhab.core.model.sitemap/bnd.bnd index 740f9512f84..5f910d37c33 100644 --- a/bundles/org.openhab.core.model.sitemap/bnd.bnd +++ b/bundles/org.openhab.core.model.sitemap/bnd.bnd @@ -17,6 +17,7 @@ Import-Package: org.apache.log4j,\ org.openhab.core.items.dto,\ org.openhab.core.model.core,\ org.openhab.core.sitemap, \ + org.openhab.core.sitemap.fileconverter, \ org.openhab.core.sitemap.registry, \ org.eclipse.xtext.xbase.lib,\ org.osgi.framework,\ diff --git a/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/SitemapRuntimeModule.xtend b/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/SitemapRuntimeModule.xtend index 4a416dec4f7..101e683388b 100644 --- a/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/SitemapRuntimeModule.xtend +++ b/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/SitemapRuntimeModule.xtend @@ -21,6 +21,8 @@ import org.eclipse.xtext.conversion.IValueConverterService import org.eclipse.xtext.linking.lazy.LazyURIEncoder import com.google.inject.Binder import com.google.inject.name.Names +import org.eclipse.xtext.formatting.IFormatter +import org.openhab.core.model.sitemap.formatting.SitemapFormatter /** * Use this class to register components to be used at runtime / without the Equinox extension registry. @@ -30,6 +32,10 @@ class SitemapRuntimeModule extends org.openhab.core.model.sitemap.AbstractSitema return SitemapConverters } + override Class bindIFormatter() { + return SitemapFormatter + } + override void configureUseIndexFragmentsForLazyLinking(Binder binder) { binder.bind(Boolean.TYPE).annotatedWith(Names.named(LazyURIEncoder.USE_INDEXED_FRAGMENTS_BINDING)).toInstance( Boolean.FALSE) diff --git a/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/formatting/SitemapFormatter.xtend b/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/formatting/SitemapFormatter.xtend index da042637103..5ab3c2f93f7 100644 --- a/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/formatting/SitemapFormatter.xtend +++ b/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/formatting/SitemapFormatter.xtend @@ -17,8 +17,8 @@ package org.openhab.core.model.sitemap.formatting import org.eclipse.xtext.formatting.impl.AbstractDeclarativeFormatter import org.eclipse.xtext.formatting.impl.FormattingConfig -// import com.google.inject.Inject; -// import org.openhab.core.model.services.SitemapGrammarAccess +import org.openhab.core.model.sitemap.services.SitemapGrammarAccess +import com.google.inject.Inject /** * This class contains custom formatting description. @@ -30,13 +30,55 @@ import org.eclipse.xtext.formatting.impl.FormattingConfig */ class SitemapFormatter extends AbstractDeclarativeFormatter { -// @Inject extension SitemapGrammarAccess - - override protected void configureFormatting(FormattingConfig c) { -// It's usually a good idea to activate the following three statements. -// They will add and preserve newlines around comments -// c.setLinewrap(0, 1, 2).before(SL_COMMENTRule) -// c.setLinewrap(0, 1, 2).before(ML_COMMENTRule) -// c.setLinewrap(0, 1, 1).after(ML_COMMENTRule) - } + @Inject extension SitemapGrammarAccess + + override protected void configureFormatting(FormattingConfig c) { + c.autoLinewrap = 200 + + c.setLinewrap(1, 1, 2).before(modelWidgetRule) + + c.setIndentationIncrement.after("{") + c.setLinewrap().before("}") + c.setIndentationDecrement.before("}") + c.setLinewrap().after("}") + + c.setNoSpace().withinKeywordPairs("[", "]") + + c.setNoSpace().after("item=", "label=", "icon=", "staticIcon=") + c.setNoSpace().after("url=", "refresh=", "encoding=", "service=", "period=", "legend=", "forceasitem=", "yAxisDecimalPattern=", "interpolation=", "height=") + c.setNoSpace().after("minValue=", "maxValue=", "step=", "inputHint=", "row=", "column=", "click=", "release=") + c.setNoSpace().after("labelcolor=", "valuecolor=", "iconcolor=", "visibility=", "mappings=", "buttons=") + + c.setNoSpace().before(",") + c.setNoSpace().around(":", "=") + + c.setLinewrap(0, 1, 2).before(SL_COMMENTRule) + c.setLinewrap(0, 1, 2).before(ML_COMMENTRule) + c.setLinewrap(0, 1, 1).after(ML_COMMENTRule) + } + + def withinKeywordPairs(FormattingConfig.NoSpaceLocator locator, String leftKW, String rightKW) { + for (pair : findKeywordPairs(leftKW, rightKW)) { + locator.after(pair.first) + locator.before(pair.second) + } + } + + def around(FormattingConfig.ElementLocator locator, String ... listKW) { + for (keyword : findKeywords(listKW)) { + locator.around(keyword) + } + } + + def after(FormattingConfig.ElementLocator locator, String ... listKW) { + for (keyword : findKeywords(listKW)) { + locator.after(keyword) + } + } + + def before(FormattingConfig.ElementLocator locator, String ... listKW) { + for (keyword : findKeywords(listKW)) { + locator.before(keyword) + } + } } diff --git a/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/SitemapProviderImpl.java b/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/DslSitemapProvider.java similarity index 81% rename from bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/SitemapProviderImpl.java rename to bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/DslSitemapProvider.java index cd0840f9323..0b809a5f5c0 100644 --- a/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/SitemapProviderImpl.java +++ b/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/DslSitemapProvider.java @@ -12,11 +12,15 @@ */ package org.openhab.core.model.sitemap.internal; +import static org.openhab.core.model.core.ModelCoreConstants.isIsolatedModel; + import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; import org.eclipse.emf.common.util.EList; import org.eclipse.emf.ecore.EObject; @@ -93,24 +97,24 @@ * @author Mark Herwege - Separate registry from model */ @NonNullByDefault -@Component(service = SitemapProvider.class, immediate = true) -public class SitemapProviderImpl extends AbstractProvider +@Component(service = { SitemapProvider.class, DslSitemapProvider.class }, immediate = true) +public class DslSitemapProvider extends AbstractProvider implements SitemapProvider, ModelRepositoryChangeListener { private static final String SITEMAP_MODEL_NAME = "sitemap"; protected static final String SITEMAP_FILEEXT = "." + SITEMAP_MODEL_NAME; protected static final String MODEL_TYPE_PREFIX = "Model"; - private final Logger logger = LoggerFactory.getLogger(SitemapProviderImpl.class); + private final Logger logger = LoggerFactory.getLogger(DslSitemapProvider.class); private final ModelRepository modelRepo; private final SitemapRegistry sitemapRegistry; private final SitemapFactory sitemapFactory; - private final Map sitemapCache = new ConcurrentHashMap<>(); + private final Map> sitemapCache = new ConcurrentHashMap<>(); @Activate - public SitemapProviderImpl(final @Reference ModelRepository modelRepo, + public DslSitemapProvider(final @Reference ModelRepository modelRepo, final @Reference SitemapRegistry sitemapRegistry, final @Reference SitemapFactory sitemapFactory) { this.modelRepo = modelRepo; this.sitemapRegistry = sitemapRegistry; @@ -129,39 +133,37 @@ protected void deactivate() { @Override public @Nullable Sitemap getSitemap(String sitemapName) { - Sitemap sitemap = sitemapCache.get(sitemapName); + Sitemap sitemap = sitemapCache.entrySet().stream().filter(e -> !isIsolatedModel(e.getKey())) + .flatMap(e -> e.getValue().stream()).filter(s -> sitemapName.equals(s.getName())).findAny() + .orElse(null); if (sitemap == null) { logger.trace("Sitemap {} cannot be found", sitemapName); } return sitemap; } + public Collection getAllFromModel(String modelName) { + return sitemapCache.getOrDefault(modelName, List.of()).stream().toList(); + } + private void refreshSitemapModels() { sitemapCache.clear(); Iterable sitemapFilenames = modelRepo.getAllModelNamesOfType(SITEMAP_MODEL_NAME); for (String filename : sitemapFilenames) { ModelSitemap modelSitemap = (ModelSitemap) modelRepo.getModel(filename); if (modelSitemap != null) { - String sitemapFileName = filename.substring(0, filename.length() - SITEMAP_FILEEXT.length()); - String sitemapName = modelSitemap.getName(); - if (!sitemapFileName.equals(sitemapName)) { - logger.warn("Filename '{}' does not match the name '{}' of the sitemap - ignoring sitemap.", - filename, sitemapName); - } else { - Sitemap sitemap = parseModelSitemap(modelSitemap); - sitemapCache.put(sitemapName, sitemap); - } + sitemapCache.put(filename, parseModelSitemap(modelSitemap)); } } } - private Sitemap parseModelSitemap(ModelSitemap modelSitemap) { + private Collection parseModelSitemap(ModelSitemap modelSitemap) { Sitemap sitemap = sitemapFactory.createSitemap(modelSitemap.getName()); sitemap.setLabel(modelSitemap.getLabel()); sitemap.setIcon(modelSitemap.getIcon()); List widgets = sitemap.getWidgets(); modelSitemap.getChildren().forEach(child -> addWidget(widgets, child, sitemap)); - return sitemap; + return List.of(sitemap); } private void addWidget(List widgets, ModelWidget modelWidget, Parent parent) { @@ -362,7 +364,7 @@ private void addRuleConditions(List conditions, EList @Override public Set getSitemapNames() { - return sitemapCache.keySet(); + return getAll().stream().map(s -> s.getName()).collect(Collectors.toSet()); } @Override @@ -371,47 +373,66 @@ public void modelChanged(String modelName, EventType type) { return; } - Sitemap sitemap = null; - String sitemapFileName = modelName.substring(0, modelName.length() - SITEMAP_FILEEXT.length()); - if (type == EventType.REMOVED) { - Sitemap oldSitemap = sitemapCache.remove(sitemapFileName); - if (oldSitemap != null) { - notifyListenersAboutRemovedElement(oldSitemap); + Collection oldSitemaps = sitemapCache.remove(modelName); + if (!isIsolatedModel(modelName) && oldSitemaps != null) { + oldSitemaps.forEach(oldSitemap -> { + notifyRemovedSitemap(modelName, oldSitemap); + }); } } else { EObject modelSitemapObject = modelRepo.getModel(modelName); // if the sitemap file is empty it will not be in the repo and thus there is no need to cache it here if (modelSitemapObject instanceof ModelSitemap modelSitemap) { - String sitemapName = modelSitemap.getName(); - if (!sitemapFileName.equals(sitemapName)) { - logger.warn("Filename '{}' does not match the name '{}' of the sitemap - ignoring sitemap.", - sitemapFileName, sitemapName); - Sitemap oldSitemap = sitemapCache.remove(sitemapFileName); - if (oldSitemap != null) { - notifyListenersAboutRemovedElement(oldSitemap); - } - } else { - sitemap = parseModelSitemap(modelSitemap); - Sitemap oldSitemap = sitemapCache.put(sitemapName, sitemap); - if (oldSitemap != null) { - notifyListenersAboutUpdatedElement(oldSitemap, sitemap); - } else { - notifyListenersAboutAddedElement(sitemap); - } + Map newSitemaps = parseModelSitemap(modelSitemap).stream() + .collect(Collectors.toMap(Sitemap::getName, Function.identity())); + if (!isIsolatedModel(modelName)) { + Map oldSitemaps = sitemapCache.getOrDefault(modelName, Set.of()).stream() + .collect(Collectors.toMap(Sitemap::getName, Function.identity())); + newSitemaps.entrySet().stream().forEach(entry -> { + if (!oldSitemaps.containsKey(entry.getKey())) { + notifyListenersAboutAddedElement(entry.getValue()); + } + }); + oldSitemaps.entrySet().stream().forEach(entry -> { + Sitemap oldSitemap = entry.getValue(); + Sitemap newSitemap = newSitemaps.get(entry.getKey()); + if (newSitemap != null) { + notifyListenersAboutUpdatedElement(oldSitemap, newSitemap); + } else { + notifyRemovedSitemap(modelName, oldSitemap); + } + }); } + sitemapCache.put(modelName, newSitemaps.values()); } else { - Sitemap oldSitemap = sitemapCache.remove(sitemapFileName); - if (oldSitemap != null) { - // Previously valid sitemap is now invalid, so no ModelSitemap was created - notifyListenersAboutRemovedElement(oldSitemap); + Collection oldSitemaps = sitemapCache.remove(modelName); + if (!isIsolatedModel(modelName) && oldSitemaps != null) { + oldSitemaps.forEach(oldSitemap -> { + notifyRemovedSitemap(modelName, oldSitemap); + }); } } } } + private void notifyRemovedSitemap(String modelName, Sitemap sitemap) { + String sitemapName = sitemap.getName(); + Sitemap existingSitemap = sitemapCache.entrySet().stream().filter(e -> !e.getKey().equals(modelName)) + .flatMap(e -> e.getValue().stream()).filter(s -> sitemapName.equals(s.getName())).findAny() + .orElse(null); + if (existingSitemap != null) { + // Another sitemap with the same name exists, so we need to update it to use the other one instead + // of the removed one + notifyListenersAboutUpdatedElement(sitemap, existingSitemap); + } else { + notifyListenersAboutRemovedElement(sitemap); + } + } + @Override public Collection getAll() { - return sitemapCache.values(); + return sitemapCache.entrySet().stream().filter(e -> !isIsolatedModel(e.getKey())) + .flatMap(e -> e.getValue().stream()).collect(Collectors.toSet()); } } diff --git a/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/fileconverter/DslSitemapConverter.java b/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/fileconverter/DslSitemapConverter.java new file mode 100644 index 00000000000..a51ec1b3ba0 --- /dev/null +++ b/bundles/org.openhab.core.model.sitemap/src/org/openhab/core/model/sitemap/internal/fileconverter/DslSitemapConverter.java @@ -0,0 +1,426 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.internal.fileconverter; + +import java.io.ByteArrayInputStream; +import java.io.OutputStream; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.emf.common.util.EList; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.model.core.ModelRepository; +import org.openhab.core.model.sitemap.internal.DslSitemapProvider; +import org.openhab.core.model.sitemap.sitemap.ModelButton; +import org.openhab.core.model.sitemap.sitemap.ModelButtonDefinition; +import org.openhab.core.model.sitemap.sitemap.ModelButtonDefinitionList; +import org.openhab.core.model.sitemap.sitemap.ModelButtongrid; +import org.openhab.core.model.sitemap.sitemap.ModelChart; +import org.openhab.core.model.sitemap.sitemap.ModelColorArray; +import org.openhab.core.model.sitemap.sitemap.ModelColorArrayList; +import org.openhab.core.model.sitemap.sitemap.ModelColorpicker; +import org.openhab.core.model.sitemap.sitemap.ModelColortemperaturepicker; +import org.openhab.core.model.sitemap.sitemap.ModelCondition; +import org.openhab.core.model.sitemap.sitemap.ModelDefault; +import org.openhab.core.model.sitemap.sitemap.ModelFrame; +import org.openhab.core.model.sitemap.sitemap.ModelGroup; +import org.openhab.core.model.sitemap.sitemap.ModelIconRule; +import org.openhab.core.model.sitemap.sitemap.ModelIconRuleList; +import org.openhab.core.model.sitemap.sitemap.ModelImage; +import org.openhab.core.model.sitemap.sitemap.ModelInput; +import org.openhab.core.model.sitemap.sitemap.ModelLinkableWidget; +import org.openhab.core.model.sitemap.sitemap.ModelMapping; +import org.openhab.core.model.sitemap.sitemap.ModelMappingList; +import org.openhab.core.model.sitemap.sitemap.ModelMapview; +import org.openhab.core.model.sitemap.sitemap.ModelSelection; +import org.openhab.core.model.sitemap.sitemap.ModelSetpoint; +import org.openhab.core.model.sitemap.sitemap.ModelSitemap; +import org.openhab.core.model.sitemap.sitemap.ModelSlider; +import org.openhab.core.model.sitemap.sitemap.ModelSwitch; +import org.openhab.core.model.sitemap.sitemap.ModelText; +import org.openhab.core.model.sitemap.sitemap.ModelVideo; +import org.openhab.core.model.sitemap.sitemap.ModelVisibilityRule; +import org.openhab.core.model.sitemap.sitemap.ModelVisibilityRuleList; +import org.openhab.core.model.sitemap.sitemap.ModelWebview; +import org.openhab.core.model.sitemap.sitemap.ModelWidget; +import org.openhab.core.model.sitemap.sitemap.SitemapFactory; +import org.openhab.core.model.sitemap.sitemap.SitemapModel; +import org.openhab.core.sitemap.Button; +import org.openhab.core.sitemap.ButtonDefinition; +import org.openhab.core.sitemap.Buttongrid; +import org.openhab.core.sitemap.Chart; +import org.openhab.core.sitemap.Colorpicker; +import org.openhab.core.sitemap.Colortemperaturepicker; +import org.openhab.core.sitemap.Condition; +import org.openhab.core.sitemap.Default; +import org.openhab.core.sitemap.Frame; +import org.openhab.core.sitemap.Group; +import org.openhab.core.sitemap.Image; +import org.openhab.core.sitemap.Input; +import org.openhab.core.sitemap.LinkableWidget; +import org.openhab.core.sitemap.Mapping; +import org.openhab.core.sitemap.Mapview; +import org.openhab.core.sitemap.Rule; +import org.openhab.core.sitemap.Selection; +import org.openhab.core.sitemap.Setpoint; +import org.openhab.core.sitemap.Sitemap; +import org.openhab.core.sitemap.Slider; +import org.openhab.core.sitemap.Switch; +import org.openhab.core.sitemap.Text; +import org.openhab.core.sitemap.Video; +import org.openhab.core.sitemap.Webview; +import org.openhab.core.sitemap.Widget; +import org.openhab.core.sitemap.fileconverter.SitemapParser; +import org.openhab.core.sitemap.fileconverter.SitemapSerializer; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link SitemapSerializer} is the DSL file converter for {@link Sitemap} object + * with the capabilities of parsing and generating file. + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = { SitemapSerializer.class, SitemapParser.class }) +public class DslSitemapConverter implements SitemapSerializer, SitemapParser { + + private final Logger logger = LoggerFactory.getLogger(DslSitemapConverter.class); + + private final ModelRepository modelRepository; + private final DslSitemapProvider sitemapProvider; + + private final Map elementsToGenerate = new ConcurrentHashMap<>(); + + @Activate + public DslSitemapConverter(final @Reference ModelRepository modelRepository, + final @Reference DslSitemapProvider sitemapProvider) { + this.modelRepository = modelRepository; + this.sitemapProvider = sitemapProvider; + } + + @Override + public String getGeneratedFormat() { + return "DSL"; + } + + @Override + public void setSitemapsToBeSerialized(String id, List sitemaps) { + if (sitemaps.isEmpty()) { + return; + } + if (sitemaps.size() > 1) { + logger.warn("Only one sitemap at a time can be serialized to DSL"); + return; + } + SitemapModel model = buildModelSitemap(sitemaps.getFirst()); + elementsToGenerate.put(id, model); + } + + @Override + public void generateFormat(String id, OutputStream out) { + SitemapModel model = elementsToGenerate.remove(id); + if (model != null) { + modelRepository.generateFileFormat(out, "sitemap", model); + } + } + + private ModelSitemap buildModelSitemap(Sitemap sitemap) { + ModelSitemap model = SitemapFactory.eINSTANCE.createModelSitemap(); + model.setName(sitemap.getName()); + model.setLabel(sitemap.getLabel()); + model.setIcon(sitemap.getIcon()); + EList childModelWidgets = model.getChildren(); + sitemap.getWidgets().forEach(childWidget -> { + ModelWidget childModelWidget = buildModelWidget(childWidget); + childModelWidgets.add(childModelWidget); + }); + return model; + } + + private ModelWidget buildModelWidget(Widget widget) { + ModelWidget modelWidget; + switch (widget) { + case Frame frameWidget -> { + ModelFrame modelFrame = SitemapFactory.eINSTANCE.createModelFrame(); + modelWidget = modelFrame; + } + case Text textWidget -> { + ModelText modelText = SitemapFactory.eINSTANCE.createModelText(); + modelWidget = modelText; + } + case Group groupWidget -> { + ModelGroup modelGroup = SitemapFactory.eINSTANCE.createModelGroup(); + modelWidget = modelGroup; + } + case Switch switchWidget -> { + ModelSwitch modelSwitch = SitemapFactory.eINSTANCE.createModelSwitch(); + List mappings = switchWidget.getMappings(); + if (!mappings.isEmpty()) { + modelSwitch.setMappings(modelMappings(mappings)); + } + modelWidget = modelSwitch; + } + case Buttongrid buttongridWidget -> { + ModelButtongrid modelButtongrid = SitemapFactory.eINSTANCE.createModelButtongrid(); + List buttons = buttongridWidget.getButtons(); + if (!buttons.isEmpty()) { + modelButtongrid.setButtons(modelButtons(buttons)); + } + modelWidget = modelButtongrid; + } + case Button buttonWidget -> { + ModelButton modelButton = SitemapFactory.eINSTANCE.createModelButton(); + modelButton.setRow(buttonWidget.getRow()); + modelButton.setColumn(buttonWidget.getColumn()); + modelButton.setCmd(buttonWidget.getCmd()); + modelButton.setReleaseCmd(buttonWidget.getReleaseCmd()); + modelButton.setStateless(buttonWidget.isStateless()); + modelWidget = modelButton; + } + case Selection selectionWidget -> { + ModelSelection modelSelection = SitemapFactory.eINSTANCE.createModelSelection(); + List mappings = selectionWidget.getMappings(); + if (!mappings.isEmpty()) { + modelSelection.setMappings(modelMappings(mappings)); + } + modelWidget = modelSelection; + } + case Setpoint setpointWidget -> { + ModelSetpoint modelSetpoint = SitemapFactory.eINSTANCE.createModelSetpoint(); + modelSetpoint.setMinValue(setpointWidget.getMinValue()); + modelSetpoint.setMaxValue(setpointWidget.getMaxValue()); + modelSetpoint.setStep(setpointWidget.getStep()); + modelWidget = modelSetpoint; + } + case Slider sliderWidget -> { + ModelSlider modelSlider = SitemapFactory.eINSTANCE.createModelSlider(); + modelSlider.setMinValue(sliderWidget.getMinValue()); + modelSlider.setMaxValue(sliderWidget.getMaxValue()); + modelSlider.setStep(sliderWidget.getStep()); + modelSlider.setSwitchEnabled(sliderWidget.isSwitchEnabled()); + modelSlider.setReleaseOnly(sliderWidget.isReleaseOnly()); + modelWidget = modelSlider; + } + case Colorpicker colorpickerWidget -> { + ModelColorpicker modelColorpicker = SitemapFactory.eINSTANCE.createModelColorpicker(); + modelWidget = modelColorpicker; + } + case Colortemperaturepicker colortemperaturepickerWidget -> { + ModelColortemperaturepicker modelColortemperaturepicker = SitemapFactory.eINSTANCE + .createModelColortemperaturepicker(); + modelColortemperaturepicker.setMinValue(colortemperaturepickerWidget.getMinValue()); + modelColortemperaturepicker.setMaxValue(colortemperaturepickerWidget.getMaxValue()); + modelWidget = modelColortemperaturepicker; + } + case Input inputWidget -> { + ModelInput modelInput = SitemapFactory.eINSTANCE.createModelInput(); + modelInput.setInputHint(inputWidget.getInputHint()); + modelWidget = modelInput; + } + case Webview webviewWidget -> { + ModelWebview modelWebview = SitemapFactory.eINSTANCE.createModelWebview(); + modelWebview.setUrl(webviewWidget.getUrl()); + modelWebview.setHeight(webviewWidget.getHeight()); + modelWidget = modelWebview; + } + case Mapview mapviewWidget -> { + ModelMapview modelMapview = SitemapFactory.eINSTANCE.createModelMapview(); + modelMapview.setHeight(mapviewWidget.getHeight()); + modelWidget = modelMapview; + } + case Image imageWidget -> { + ModelImage modelImage = SitemapFactory.eINSTANCE.createModelImage(); + modelImage.setUrl(imageWidget.getUrl()); + modelImage.setRefresh(imageWidget.getRefresh()); + modelWidget = modelImage; + } + case Video videoWidget -> { + ModelVideo modelVideo = SitemapFactory.eINSTANCE.createModelVideo(); + modelVideo.setUrl(videoWidget.getUrl()); + modelVideo.setEncoding(videoWidget.getEncoding()); + modelWidget = modelVideo; + } + case Chart chartWidget -> { + ModelChart modelChart = SitemapFactory.eINSTANCE.createModelChart(); + modelChart.setRefresh(chartWidget.getRefresh()); + modelChart.setPeriod(chartWidget.getPeriod()); + modelChart.setService(chartWidget.getService()); + modelChart.setLegend(chartWidget.hasLegend()); + modelChart.setForceAsItem(chartWidget.forceAsItem()); + modelChart.setYAxisDecimalPattern(chartWidget.getYAxisDecimalPattern()); + modelChart.setInterpolation(chartWidget.getInterpolation()); + modelWidget = modelChart; + } + default -> { + ModelDefault modelDefault = SitemapFactory.eINSTANCE.createModelDefault(); + if (widget instanceof Default defaultWidget) { + modelDefault.setHeight(defaultWidget.getHeight()); + } + modelWidget = modelDefault; + } + } + + modelWidget.setItem(widget.getItem()); + modelWidget.setLabel(widget.getLabel()); + String icon = widget.getIcon(); + if (widget.isStaticIcon()) { + modelWidget.setStaticIcon(icon); + } else { + modelWidget.setIcon(icon); + } + + List iconRules = widget.getIconRules(); + if (!iconRules.isEmpty()) { + modelWidget.setIconRules(modelIconRules(iconRules)); + } + List visibilityRules = widget.getVisibility(); + if (!visibilityRules.isEmpty()) { + modelWidget.setVisibility(modelVisibilityRules(visibilityRules)); + } + List labelColorRules = widget.getLabelColor(); + if (!labelColorRules.isEmpty()) { + modelWidget.setLabelColor(modelColorRules(labelColorRules)); + } + List valueColorRules = widget.getValueColor(); + if (!valueColorRules.isEmpty()) { + modelWidget.setValueColor(modelColorRules(valueColorRules)); + } + List iconColorRules = widget.getIconColor(); + if (!iconColorRules.isEmpty()) { + modelWidget.setIconColor(modelColorRules(iconColorRules)); + } + + if (widget instanceof LinkableWidget linkableWidget + && modelWidget instanceof ModelLinkableWidget modelLinkableWidget) { + EList childModelWidgets = modelLinkableWidget.getChildren(); + linkableWidget.getWidgets().forEach(childWidget -> { + ModelWidget childModelWidget = buildModelWidget(childWidget); + childModelWidgets.add(childModelWidget); + }); + } + return modelWidget; + } + + private ModelMappingList modelMappings(List mappings) { + ModelMappingList modelMappingList = SitemapFactory.eINSTANCE.createModelMappingList(); + EList modelMappings = modelMappingList.getElements(); + mappings.forEach(mapping -> { + ModelMapping modelMapping = SitemapFactory.eINSTANCE.createModelMapping(); + modelMapping.setCmd(mapping.getCmd()); + modelMapping.setReleaseCmd(mapping.getReleaseCmd()); + modelMapping.setLabel(mapping.getLabel()); + modelMapping.setIcon(mapping.getIcon()); + modelMappings.add(modelMapping); + }); + return modelMappingList; + } + + private ModelButtonDefinitionList modelButtons(List buttons) { + ModelButtonDefinitionList modelButtonList = SitemapFactory.eINSTANCE.createModelButtonDefinitionList(); + EList modelButtons = modelButtonList.getElements(); + buttons.forEach(button -> { + ModelButtonDefinition modelButton = SitemapFactory.eINSTANCE.createModelButtonDefinition(); + modelButton.setRow(button.getRow()); + modelButton.setColumn(button.getColumn()); + modelButton.setCmd(button.getCmd()); + modelButton.setLabel(button.getLabel()); + modelButton.setIcon(button.getIcon()); + modelButtons.add(modelButton); + }); + return modelButtonList; + } + + private ModelIconRuleList modelIconRules(List rules) { + ModelIconRuleList modelRuleList = SitemapFactory.eINSTANCE.createModelIconRuleList(); + EList modelRules = modelRuleList.getElements(); + rules.forEach(rule -> { + ModelIconRule modelRule = SitemapFactory.eINSTANCE.createModelIconRule(); + EList modelConditions = modelRule.getConditions(); + List conditions = rule.getConditions(); + setModelConditions(modelConditions, conditions); + modelRule.setArg(rule.getArgument()); + modelRules.add(modelRule); + }); + return modelRuleList; + } + + private ModelVisibilityRuleList modelVisibilityRules(List rules) { + ModelVisibilityRuleList modelRuleList = SitemapFactory.eINSTANCE.createModelVisibilityRuleList(); + EList modelRules = modelRuleList.getElements(); + rules.forEach(rule -> { + ModelVisibilityRule modelRule = SitemapFactory.eINSTANCE.createModelVisibilityRule(); + EList modelConditions = modelRule.getConditions(); + List conditions = rule.getConditions(); + setModelConditions(modelConditions, conditions); + modelRules.add(modelRule); + }); + return modelRuleList; + } + + private ModelColorArrayList modelColorRules(List rules) { + ModelColorArrayList modelRuleList = SitemapFactory.eINSTANCE.createModelColorArrayList(); + EList modelRules = modelRuleList.getElements(); + rules.forEach(rule -> { + ModelColorArray modelRule = SitemapFactory.eINSTANCE.createModelColorArray(); + EList modelConditions = modelRule.getConditions(); + List conditions = rule.getConditions(); + setModelConditions(modelConditions, conditions); + modelRule.setArg(rule.getArgument()); + modelRules.add(modelRule); + }); + return modelRuleList; + } + + private void setModelConditions(EList modelConditions, List conditions) { + conditions.forEach(condition -> { + ModelCondition modelCondition = SitemapFactory.eINSTANCE.createModelCondition(); + modelCondition.setItem(condition.getItem()); + modelCondition.setCondition(condition.getCondition()); + String value = condition.getValue(); + if (value.length() > 1 && (value.startsWith("-") || value.startsWith("+"))) { + modelCondition.setSign(value.substring(0, 1)); + value = value.substring(1); + } + modelCondition.setState(value); + modelConditions.add(modelCondition); + }); + } + + @Override + public String getParserFormat() { + return "DSL"; + } + + @Override + public @Nullable String startParsingFormat(String syntax, List errors, List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + return modelRepository.createIsolatedModel("sitemap", inputStream, errors, warnings); + } + + @Override + public Collection getParsedObjects(String modelName) { + return sitemapProvider.getAllFromModel(modelName); + } + + @Override + public void finishParsingFormat(String modelName) { + modelRepository.removeModel(modelName); + } +} diff --git a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingConverter.java b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingConverter.java index 1096e1b9125..7d9a01862d1 100644 --- a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingConverter.java +++ b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingConverter.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.io.OutputStream; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.HashSet; import java.util.List; @@ -241,7 +242,7 @@ public String getParserFormat() { @Override public @Nullable String startParsingFormat(String syntax, List errors, List warnings) { - ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes(StandardCharsets.UTF_8)); return modelRepository.createIsolatedModel("things", inputStream, errors, warnings); } diff --git a/bundles/org.openhab.core.model.yaml/pom.xml b/bundles/org.openhab.core.model.yaml/pom.xml index cf6e33fe798..acafdd34bbd 100644 --- a/bundles/org.openhab.core.model.yaml/pom.xml +++ b/bundles/org.openhab.core.model.yaml/pom.xml @@ -40,5 +40,10 @@ org.openhab.core.automation ${project.version} + + org.openhab.core.bundles + org.openhab.core.sitemap + 5.2.0-SNAPSHOT + diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java index e75bc3e844e..58cac26d9e0 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java @@ -48,6 +48,7 @@ import org.openhab.core.model.yaml.internal.rules.YamlRuleDTO; import org.openhab.core.model.yaml.internal.rules.YamlRuleTemplateDTO; import org.openhab.core.model.yaml.internal.semantics.YamlSemanticTagDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlSitemapDTO; import org.openhab.core.model.yaml.internal.things.YamlThingDTO; import org.openhab.core.service.WatchService; import org.openhab.core.service.WatchService.Kind; @@ -97,14 +98,15 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener, getElementName(YamlRuleTemplateDTO.class), // "ruleTemplates" getElementName(YamlSemanticTagDTO.class), // "tags" getElementName(YamlThingDTO.class), // "things" - getElementName(YamlItemDTO.class) // "items" + getElementName(YamlItemDTO.class), // "items" + getElementName(YamlSitemapDTO.class) // "sitemaps" ); 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 WATCHED_PATHS = Stream.of("things", "items", "tags", "rules", "yaml").map(Path::of) - .toList(); + private static final List WATCHED_PATHS = Stream.of("things", "items", "tags", "sitemaps", "rules", "yaml") + .map(Path::of).toList(); private final Logger logger = LoggerFactory.getLogger(YamlModelRepositoryImpl.class); diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java index 6277308ef0f..7c19b549956 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java @@ -133,12 +133,11 @@ public boolean isValid(@Nullable List<@NonNull String> errors, @Nullable List<@N "item \"%s\": \"dimension\" field ignored as type is not Number".formatted(name, dimension)); } } - if (icon != null) { - subErrors.clear(); - ok &= isValidIcon(icon, subErrors); - subErrors.forEach(error -> { - addToList(errors, "invalid item \"%s\": %s".formatted(name, error)); - }); + if (icon != null && !YamlElementUtils.isValidIcon(icon)) { + addToList(errors, + "invalid item \"%s\": invalid value \"%s\" for \"icon\" field; it must contain a maximum of 3 segments separated by a colon, each segment matching pattern [a-zA-Z0-9_][a-zA-Z0-9_-]*" + .formatted(name, icon)); + ok = false; } if (groups != null) { for (String gr : groups) { @@ -206,26 +205,6 @@ public boolean isValid(@Nullable List<@NonNull String> errors, @Nullable List<@N return ok; } - private boolean isValidIcon(String icon, List<@NonNull String> errors) { - boolean ok = true; - String[] segments = icon.split(AbstractUID.SEPARATOR); - int nb = segments.length; - if (nb > 3) { - errors.add("too many segments in value \"%s\" for \"icon\" field; maximum 3 is expected".formatted(icon)); - ok = false; - nb = 3; - } - for (int i = 0; i < nb; i++) { - String segment = segments[i]; - if (!ICON_SEGMENT_PATTERN.matcher(segment).matches()) { - errors.add("segment \"%s\" in \"icon\" field not matching the expected syntax %s".formatted(segment, - ICON_SEGMENT_PATTERN.pattern())); - ok = false; - } - } - return ok; - } - private boolean isValidChannel(String channelUID, @Nullable Map<@NonNull String, @NonNull Object> configuration, List<@NonNull String> errors) { boolean ok = true; diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlColorRuleDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlColorRuleDTO.java new file mode 100644 index 00000000000..f24c8db7915 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlColorRuleDTO.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +/** + * This is a data transfer object that is used to serialize color rules. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlColorRuleDTO { + + public String value; + public String item; + public String operator; + public String argument; + public List and; + + public YamlColorRuleDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + boolean ok = true; + if (value == null) { + addToList(errors, "\"value\" field missing while mandatory"); + ok = false; + } + if (and != null && !and.isEmpty()) { + for (YamlConditionDTO condition : and) { + ok &= condition.isValid(errors, warnings); + } + } + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + @Override + public int hashCode() { + return Objects.hash(value, item, operator, argument, and); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlColorRuleDTO other = (YamlColorRuleDTO) obj; + return Objects.equals(value, other.value) && Objects.equals(item, other.item) + && Objects.equals(operator, other.operator) && Objects.equals(argument, other.argument) + && Objects.equals(and, other.and); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlConditionDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlConditionDTO.java new file mode 100644 index 00000000000..4c9a51da4e2 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlConditionDTO.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.items.ItemUtil; + +/** + * This is a data transfer object that is used to serialize rule conditions. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlConditionDTO { + + private static final Set ALLOWED_CONDITIONS = Set.of("==", "!=", "<", ">", "<=", ">="); + + public String item; + public String operator; + public String argument; + + public YamlConditionDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + boolean ok = true; + if (item != null && !ItemUtil.isValidItemName(item)) { + addToList(errors, + "invalid value \"%s\" for \"item\" field in condition; it must begin with a letter or underscore followed by alphanumeric characters and underscores, and must not contain any other symbols" + .formatted(item)); + ok = false; + } + if (operator != null && !ALLOWED_CONDITIONS.contains(operator)) { + addToList(errors, "invalid value \"%s\" for \"operator\" field in condition".formatted(operator)); + ok = false; + } + if (argument == null) { + addToList(errors, "\"argument\" field missing while mandatory in condition"); + ok = false; + } + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + @Override + public int hashCode() { + return Objects.hash(item, operator, argument); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlConditionDTO other = (YamlConditionDTO) obj; + return Objects.equals(item, other.item) && Objects.equals(operator, other.operator) + && Objects.equals(argument, other.argument); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlIconRuleDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlIconRuleDTO.java new file mode 100644 index 00000000000..d5096074a11 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlIconRuleDTO.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; + +/** + * This is a data transfer object that is used to serialize icon rules. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlIconRuleDTO { + + public String value; + public String item; + public String operator; + public String argument; + public List and; + + public YamlIconRuleDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + boolean ok = true; + if (value == null) { + addToList(errors, "\"value\" field missing while mandatory"); + ok = false; + } else if (!YamlElementUtils.isValidIcon(value)) { + addToList(errors, + "invalid value \"%s\" for \"value\" field; it must contain a maximum of 3 segments separated by a colon, each segment matching pattern [a-zA-Z0-9_][a-zA-Z0-9_-]*" + .formatted(value)); + ok = false; + } + if (and != null && !and.isEmpty()) { + for (YamlConditionDTO condition : and) { + ok &= condition.isValid(errors, warnings); + } + } + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + @Override + public int hashCode() { + return Objects.hash(value, item, operator, argument, and); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlIconRuleDTO other = (YamlIconRuleDTO) obj; + return Objects.equals(value, other.value) && Objects.equals(item, other.item) + && Objects.equals(operator, other.operator) && Objects.equals(argument, other.argument) + && Objects.equals(and, other.and); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlMappingDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlMappingDTO.java new file mode 100644 index 00000000000..06ea777f2a4 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlMappingDTO.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; + +/** + * This is a data transfer object that is used to serialize command mappings. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlMappingDTO { + + public String command; + public String releaseCommand; + public String label; + public String icon; + + public YamlMappingDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + boolean ok = true; + if (command == null) { + addToList(errors, "\"command\" field missing while mandatory in mappings definition"); + ok = false; + } + if (label == null) { + addToList(errors, "\"label\" field missing while mandatory in mappings definition"); + ok = false; + } + if (icon != null && !YamlElementUtils.isValidIcon(icon)) { + addToList(errors, + "invalid value \"%s\" for \"icon\" field in mappings definition; it must contain a maximum of 3 segments separated by a colon, each segment matching pattern [a-zA-Z0-9_][a-zA-Z0-9_-]*" + .formatted(icon)); + ok = false; + } + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + @Override + public int hashCode() { + return Objects.hash(command, releaseCommand, label, icon); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlMappingDTO other = (YamlMappingDTO) obj; + return Objects.equals(command, other.command) && Objects.equals(releaseCommand, other.releaseCommand) + && Objects.equals(label, other.label) && Objects.equals(icon, other.icon); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlSitemapDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlSitemapDTO.java new file mode 100644 index 00000000000..7066555eb3a --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlSitemapDTO.java @@ -0,0 +1,467 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.dto.ModularDTO; +import org.openhab.core.io.dto.SerializationException; +import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlElementName; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * The {@link YamlSitemapDTO} is a data transfer object used to serialize a UI sitemap in a YAML configuration file. + * + * @author Laurent Garnier - Initial contribution + */ +@YamlElementName("sitemaps") +public class YamlSitemapDTO implements ModularDTO, YamlElement, Cloneable { + + private static final String NAME_PATTERN = "^[a-zA-Z0-9_]+$"; + + public String name; + public String label; + public String icon; + public List widgets; + + public YamlSitemapDTO() { + } + + @Override + public @NonNull String getId() { + return name == null ? "" : name; + } + + @Override + public void setId(@NonNull String id) { + name = id; + } + + @Override + public YamlElement cloneWithoutId() { + YamlSitemapDTO copy; + try { + copy = (YamlSitemapDTO) super.clone(); + copy.name = null; + return copy; + } catch (CloneNotSupportedException e) { + // Will never happen + return new YamlSitemapDTO(); + } + } + + @Override + public boolean isValid(@Nullable List<@NonNull String> errors, @Nullable List<@NonNull String> warnings) { + // Check that name is present + if (name == null || name.isBlank()) { + addToList(errors, "invalid sitemap: name missing while mandatory"); + return false; + } + boolean ok = true; + if (!name.matches(NAME_PATTERN)) { + addToList(errors, + "invalid sitemap \"%s\": name must contain alphanumeric characters and underscores, and must not contain any other symbols." + .formatted(name)); + ok = false; + } + if (icon != null && !YamlElementUtils.isValidIcon(icon)) { + addToList(errors, + "invalid sitemap \"%s\": invalid value \"%s\" for \"icon\" field; it must contain a maximum of 3 segments separated by a colon, each segment matching pattern [a-zA-Z0-9_][a-zA-Z0-9_-]*" + .formatted(name, icon)); + ok = false; + } + if (widgets == null) { + addToList(errors, "invalid sitemap \"%s\": \"widgets\" field missing while mandatory".formatted(name)); + ok = false; + } else if (widgets.isEmpty()) { + addToList(errors, + "invalid sitemap \"%s\": \"widgets\" field must define at least one widget".formatted(name)); + ok = false; + } else { + for (int i = 0; i < widgets.size(); i++) { + String id = "%d/%d".formatted(i + 1, widgets.size()); + YamlWidgetDTO widget = widgets.get(i); + String wType = widget.type == null ? "?" : widget.type; + List widgetErrors = new ArrayList<>(); + List widgetWarnings = new ArrayList<>(); + ok &= widget.isValid(widgetErrors, widgetWarnings); + widgetErrors.forEach(error -> { + addToList(errors, + "invalid sitemap \"%s\": widget %s of type %s: %s".formatted(name, id, wType, error)); + }); + widgetWarnings.forEach(warning -> { + addToList(warnings, "sitemap \"%s\": widget %s of type %s: %s".formatted(name, id, wType, warning)); + }); + } + } + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + @Override + public @NonNull YamlSitemapDTO toDto(@NonNull JsonNode node, @NonNull ObjectMapper mapper) + throws SerializationException { + YamlPartialSitemapDTO partial; + YamlSitemapDTO result = new YamlSitemapDTO(); + try { + partial = mapper.treeToValue(node, YamlPartialSitemapDTO.class); + result.name = partial.name; + result.label = partial.label; + result.icon = partial.icon; + if (partial.widgets != null) { + List widgets = new ArrayList<>(partial.widgets.size()); + for (YamlPartialWidgetDTO widget : partial.widgets) { + widgets.add(toDto(widget, mapper)); + } + result.widgets = widgets; + } + } catch (JsonProcessingException | IllegalArgumentException e) { + throw new SerializationException(e.getMessage(), e); + } + return result; + } + + private @NonNull YamlWidgetDTO toDto(@NonNull YamlPartialWidgetDTO partial, @NonNull ObjectMapper mapper) + throws SerializationException { + JsonNode valueNode, objNode, ruleNode, conditionsNode, conditionNode; + YamlWidgetDTO result = new YamlWidgetDTO(); + result.type = partial.type; + result.item = partial.item; + result.mappings = partial.mappings; + result.switchSupport = partial.switchSupport; + result.releaseOnly = partial.releaseOnly; + result.height = partial.height; + result.min = partial.min; + result.max = partial.max; + result.step = partial.step; + result.hint = partial.hint; + result.url = partial.url; + result.refresh = partial.refresh; + result.encoding = partial.encoding; + result.service = partial.service; + result.period = partial.period; + result.legend = partial.legend; + result.forceAsItem = partial.forceAsItem; + result.yAxisDecimalPattern = partial.yAxisDecimalPattern; + result.interpolation = partial.interpolation; + result.row = partial.row; + result.column = partial.column; + result.command = partial.command; + result.releaseCommand = partial.releaseCommand; + result.stateless = partial.stateless; + result.visibility = partial.visibility; + + if (partial.label != null && partial.label.isValueNode()) { + String label = partial.label.asText(); + String format = null; + if (label != null) { + int idx = label.indexOf("["); + if (idx >= 0) { + format = label.substring(idx + 1, label.length() - 1).trim(); + label = label.substring(0, idx).trim(); + } + } + YamlWidgetLabelDTO widgetLabel = new YamlWidgetLabelDTO(); + widgetLabel.label = label; + widgetLabel.format = format; + result.label = widgetLabel; + } else if (partial.label instanceof ObjectNode objectNode) { + YamlWidgetLabelDTO widgetLabel = new YamlWidgetLabelDTO(); + if ((valueNode = objectNode.get("label")) != null && valueNode.isValueNode()) { + widgetLabel.label = valueNode.asText(); + } + if ((valueNode = objectNode.get("format")) != null && valueNode.isValueNode()) { + widgetLabel.format = valueNode.asText(); + } + if ((valueNode = objectNode.get("labelColor")) != null && valueNode.isValueNode()) { + YamlColorRuleDTO rule = new YamlColorRuleDTO(); + rule.value = valueNode.asText(); + widgetLabel.labelColor = List.of(rule); + } else if ((objNode = objectNode.get("labelColor")) != null && objNode.isArray()) { + List rules = new ArrayList<>(objNode.size()); + for (Iterator iterator = objNode.elements(); iterator.hasNext();) { + ruleNode = iterator.next(); + YamlColorRuleDTO rule = new YamlColorRuleDTO(); + if ((valueNode = ruleNode.get("value")) != null && valueNode.isValueNode()) { + rule.value = valueNode.asText(); + } + if ((conditionsNode = ruleNode.get("and")) != null && conditionsNode.isArray()) { + List conditions = new ArrayList<>(conditionsNode.size()); + for (Iterator iterator2 = conditionsNode.elements(); iterator2.hasNext();) { + conditionNode = iterator2.next(); + YamlConditionDTO condition = new YamlConditionDTO(); + if ((valueNode = conditionNode.get("item")) != null && valueNode.isValueNode()) { + condition.item = valueNode.asText(); + } + if ((valueNode = conditionNode.get("operator")) != null && valueNode.isValueNode()) { + condition.operator = valueNode.asText(); + } + if ((valueNode = conditionNode.get("argument")) != null && valueNode.isValueNode()) { + condition.argument = valueNode.asText(); + } + conditions.add(condition); + } + rule.and = conditions; + } else { + YamlConditionDTO condition = new YamlConditionDTO(); + if ((valueNode = ruleNode.get("item")) != null && valueNode.isValueNode()) { + condition.item = valueNode.asText(); + } + if ((valueNode = ruleNode.get("operator")) != null && valueNode.isValueNode()) { + condition.operator = valueNode.asText(); + } + if ((valueNode = ruleNode.get("argument")) != null && valueNode.isValueNode()) { + condition.argument = valueNode.asText(); + rule.and = List.of(condition); + } + } + rules.add(rule); + } + widgetLabel.labelColor = rules; + } + if ((valueNode = objectNode.get("valueColor")) != null && valueNode.isValueNode()) { + YamlColorRuleDTO rule = new YamlColorRuleDTO(); + rule.value = valueNode.asText(); + widgetLabel.valueColor = List.of(rule); + } else if ((objNode = objectNode.get("valueColor")) != null && objNode.isArray()) { + List rules = new ArrayList<>(objNode.size()); + for (Iterator iterator = objNode.elements(); iterator.hasNext();) { + ruleNode = iterator.next(); + YamlColorRuleDTO rule = new YamlColorRuleDTO(); + if ((valueNode = ruleNode.get("value")) != null && valueNode.isValueNode()) { + rule.value = valueNode.asText(); + } + if ((conditionsNode = ruleNode.get("and")) != null && conditionsNode.isArray()) { + List conditions = new ArrayList<>(conditionsNode.size()); + for (Iterator iterator2 = conditionsNode.elements(); iterator2.hasNext();) { + conditionNode = iterator2.next(); + YamlConditionDTO condition = new YamlConditionDTO(); + if ((valueNode = conditionNode.get("item")) != null && valueNode.isValueNode()) { + condition.item = valueNode.asText(); + } + if ((valueNode = conditionNode.get("operator")) != null && valueNode.isValueNode()) { + condition.operator = valueNode.asText(); + } + if ((valueNode = conditionNode.get("argument")) != null && valueNode.isValueNode()) { + condition.argument = valueNode.asText(); + } + conditions.add(condition); + } + rule.and = conditions; + } else { + YamlConditionDTO condition = new YamlConditionDTO(); + if ((valueNode = ruleNode.get("item")) != null && valueNode.isValueNode()) { + condition.item = valueNode.asText(); + } + if ((valueNode = ruleNode.get("operator")) != null && valueNode.isValueNode()) { + condition.operator = valueNode.asText(); + } + if ((valueNode = ruleNode.get("argument")) != null && valueNode.isValueNode()) { + condition.argument = valueNode.asText(); + rule.and = List.of(condition); + } + } + rules.add(rule); + } + widgetLabel.valueColor = rules; + } + result.label = widgetLabel; + } + + if (partial.icon != null && partial.icon.isValueNode()) { + YamlWidgetIconDTO widgetIcon = new YamlWidgetIconDTO(); + widgetIcon.name = partial.icon.asText(); + result.icon = widgetIcon; + } else if (partial.icon instanceof ObjectNode objectNode) { + YamlWidgetIconDTO widgetIcon = new YamlWidgetIconDTO(); + if ((valueNode = objectNode.get("name")) != null && valueNode.isValueNode()) { + widgetIcon.name = valueNode.asText(); + } else if ((objNode = objectNode.get("name")) != null && objNode.isArray()) { + List rules = new ArrayList<>(objNode.size()); + for (Iterator iterator = objNode.elements(); iterator.hasNext();) { + ruleNode = iterator.next(); + YamlIconRuleDTO rule = new YamlIconRuleDTO(); + if ((valueNode = ruleNode.get("value")) != null && valueNode.isValueNode()) { + rule.value = valueNode.asText(); + } + if ((conditionsNode = ruleNode.get("and")) != null && conditionsNode.isArray()) { + List conditions = new ArrayList<>(conditionsNode.size()); + for (Iterator iterator2 = conditionsNode.elements(); iterator2.hasNext();) { + conditionNode = iterator2.next(); + YamlConditionDTO condition = new YamlConditionDTO(); + if ((valueNode = conditionNode.get("item")) != null && valueNode.isValueNode()) { + condition.item = valueNode.asText(); + } + if ((valueNode = conditionNode.get("operator")) != null && valueNode.isValueNode()) { + condition.operator = valueNode.asText(); + } + if ((valueNode = conditionNode.get("argument")) != null && valueNode.isValueNode()) { + condition.argument = valueNode.asText(); + } + conditions.add(condition); + } + rule.and = conditions; + } else { + YamlConditionDTO condition = new YamlConditionDTO(); + if ((valueNode = ruleNode.get("item")) != null && valueNode.isValueNode()) { + condition.item = valueNode.asText(); + } + if ((valueNode = ruleNode.get("operator")) != null && valueNode.isValueNode()) { + condition.operator = valueNode.asText(); + } + if ((valueNode = ruleNode.get("argument")) != null && valueNode.isValueNode()) { + condition.argument = valueNode.asText(); + rule.and = List.of(condition); + } + } + rules.add(rule); + } + widgetIcon.name = rules; + } + if ((valueNode = objectNode.get("static")) != null && valueNode.isBoolean()) { + widgetIcon.staticIcon = valueNode.asBoolean(); + } + if ((valueNode = objectNode.get("color")) != null && valueNode.isValueNode()) { + YamlColorRuleDTO rule = new YamlColorRuleDTO(); + rule.value = valueNode.asText(); + widgetIcon.color = List.of(rule); + } else if ((objNode = objectNode.get("color")) != null && objNode.isArray()) { + List rules = new ArrayList<>(objNode.size()); + for (Iterator iterator = objNode.elements(); iterator.hasNext();) { + ruleNode = iterator.next(); + YamlColorRuleDTO rule = new YamlColorRuleDTO(); + if ((valueNode = ruleNode.get("value")) != null && valueNode.isValueNode()) { + rule.value = valueNode.asText(); + } + if ((conditionsNode = ruleNode.get("and")) != null && conditionsNode.isArray()) { + List conditions = new ArrayList<>(conditionsNode.size()); + for (Iterator iterator2 = conditionsNode.elements(); iterator2.hasNext();) { + conditionNode = iterator2.next(); + YamlConditionDTO condition = new YamlConditionDTO(); + if ((valueNode = conditionNode.get("item")) != null && valueNode.isValueNode()) { + condition.item = valueNode.asText(); + } + if ((valueNode = conditionNode.get("operator")) != null && valueNode.isValueNode()) { + condition.operator = valueNode.asText(); + } + if ((valueNode = conditionNode.get("argument")) != null && valueNode.isValueNode()) { + condition.argument = valueNode.asText(); + } + conditions.add(condition); + } + rule.and = conditions; + } else { + YamlConditionDTO condition = new YamlConditionDTO(); + if ((valueNode = ruleNode.get("item")) != null && valueNode.isValueNode()) { + condition.item = valueNode.asText(); + } + if ((valueNode = ruleNode.get("operator")) != null && valueNode.isValueNode()) { + condition.operator = valueNode.asText(); + } + if ((valueNode = ruleNode.get("argument")) != null && valueNode.isValueNode()) { + condition.argument = valueNode.asText(); + rule.and = List.of(condition); + } + } + rules.add(rule); + } + widgetIcon.color = rules; + } + result.icon = widgetIcon; + } + + if (partial.widgets != null) { + List widgets = new ArrayList<>(partial.widgets.size()); + for (YamlPartialWidgetDTO widget : partial.widgets) { + widgets.add(toDto(widget, mapper)); + } + result.widgets = widgets; + } + + return result; + } + + @Override + public int hashCode() { + return Objects.hash(name, label, icon, widgets); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlSitemapDTO other = (YamlSitemapDTO) obj; + return Objects.equals(name, other.name) && Objects.equals(label, other.label) + && Objects.equals(icon, other.icon) && Objects.equals(widgets, other.widgets); + } + + protected static class YamlPartialSitemapDTO { + public String name; + public String label; + public String icon; + public List widgets; + } + + protected static class YamlPartialWidgetDTO { + public String type; + public String item; + public JsonNode label; + public JsonNode icon; + public List mappings; + public Boolean switchSupport; + public Boolean releaseOnly; + public Integer height; + public BigDecimal min; + public BigDecimal max; + @JsonAlias({ "stepsize" }) + public BigDecimal step; + public String hint; + public String url; + public Integer refresh; + public String encoding; + public String service; + public String period; + public Boolean legend; + public Boolean forceAsItem; + public String yAxisDecimalPattern; + public String interpolation; + public Integer row; + public Integer column; + public String command; + public String releaseCommand; + public Boolean stateless; + public List visibility; + public List widgets; + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlSitemapProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlSitemapProvider.java new file mode 100644 index 00000000000..a539b455894 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlSitemapProvider.java @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import static org.openhab.core.model.yaml.YamlModelUtils.isIsolatedModel; + +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 java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.registry.AbstractProvider; +import org.openhab.core.model.yaml.YamlModelListener; +import org.openhab.core.sitemap.Button; +import org.openhab.core.sitemap.Chart; +import org.openhab.core.sitemap.Colortemperaturepicker; +import org.openhab.core.sitemap.Condition; +import org.openhab.core.sitemap.Default; +import org.openhab.core.sitemap.Image; +import org.openhab.core.sitemap.Input; +import org.openhab.core.sitemap.LinkableWidget; +import org.openhab.core.sitemap.Mapping; +import org.openhab.core.sitemap.Mapview; +import org.openhab.core.sitemap.Parent; +import org.openhab.core.sitemap.Rule; +import org.openhab.core.sitemap.Selection; +import org.openhab.core.sitemap.Setpoint; +import org.openhab.core.sitemap.Sitemap; +import org.openhab.core.sitemap.Slider; +import org.openhab.core.sitemap.Switch; +import org.openhab.core.sitemap.Video; +import org.openhab.core.sitemap.Webview; +import org.openhab.core.sitemap.Widget; +import org.openhab.core.sitemap.registry.SitemapFactory; +import org.openhab.core.sitemap.registry.SitemapProvider; +import org.openhab.core.sitemap.registry.SitemapRegistry; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link YamlSitemapProvider} is an OSGi service, that allows to define UI sitemaps in YAML configuration files. + * Files can be added, updated or removed at runtime. + * These sitemaps are automatically exposed to the {@link SitemapRegistry}. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = { SitemapProvider.class, YamlSitemapProvider.class, YamlModelListener.class }) +public class YamlSitemapProvider extends AbstractProvider + implements SitemapProvider, YamlModelListener { + + private final Logger logger = LoggerFactory.getLogger(YamlSitemapProvider.class); + + private SitemapFactory sitemapFactory; + + private final Map> sitemapsMap = new ConcurrentHashMap<>(); + + @Activate + public YamlSitemapProvider(final @Reference SitemapFactory sitemapFactory, Map properties) { + this.sitemapFactory = sitemapFactory; + } + + @Deactivate + public void deactivate() { + sitemapsMap.clear(); + } + + @Override + public Collection getAll() { + // Ignore isolated models + return sitemapsMap.keySet().stream().filter(name -> !isIsolatedModel(name)) + .map(name -> sitemapsMap.getOrDefault(name, List.of())).flatMap(list -> list.stream()).toList(); + } + + public Collection getAllFromModel(String modelName) { + return sitemapsMap.getOrDefault(modelName, List.of()); + } + + @Override + public Class getElementClass() { + return YamlSitemapDTO.class; + } + + @Override + public boolean isVersionSupported(int version) { + return version >= 1; + } + + @Override + public boolean isDeprecated() { + return false; + } + + @Override + public void addedModel(String modelName, Collection elements) { + Map added = new LinkedHashMap<>(); + elements.forEach(elt -> { + Sitemap sitemap = mapSitemap(elt); + if (sitemap != null) { + added.put(sitemap, elt); + } + }); + + Collection modelSitemaps = Objects + .requireNonNull(sitemapsMap.computeIfAbsent(modelName, k -> new ArrayList<>())); + modelSitemaps.addAll(added.keySet()); + + added.forEach((sitemap, sitemapDTO) -> { + String name = sitemap.getName(); + logger.debug("model {} added sitemap {}", modelName, name); + if (!isIsolatedModel(modelName)) { + notifyListenersAboutAddedElement(sitemap); + } + }); + } + + @Override + public void updatedModel(String modelName, Collection elements) { + Map updated = new LinkedHashMap<>(); + elements.forEach(elt -> { + Sitemap sitemap = mapSitemap(elt); + if (sitemap != null) { + updated.put(sitemap, elt); + } + }); + + Collection modelSitemaps = Objects + .requireNonNull(sitemapsMap.computeIfAbsent(modelName, k -> new ArrayList<>())); + updated.forEach((sitemap, sitemapDTO) -> { + String name = sitemap.getName(); + modelSitemaps.stream().filter(s -> s.getName().equals(name)).findFirst().ifPresentOrElse(oldSitemap -> { + modelSitemaps.remove(oldSitemap); + modelSitemaps.add(sitemap); + logger.debug("model {} updated sitemap {}", modelName, name); + if (!isIsolatedModel(modelName)) { + notifyListenersAboutUpdatedElement(oldSitemap, sitemap); + } + }, () -> { + modelSitemaps.add(sitemap); + logger.debug("model {} added sitemap {}", modelName, name); + if (!isIsolatedModel(modelName)) { + notifyListenersAboutAddedElement(sitemap); + } + }); + }); + } + + @Override + public void removedModel(String modelName, Collection elements) { + Collection modelSitemaps = sitemapsMap.getOrDefault(modelName, List.of()); + elements.stream().map(elt -> elt.name).forEach(name -> { + modelSitemaps.stream().filter(s -> s.getName().equals(name)).findFirst().ifPresentOrElse(oldSitemap -> { + modelSitemaps.remove(oldSitemap); + logger.debug("model {} removed sitemap {}", modelName, name); + if (!isIsolatedModel(modelName)) { + notifyListenersAboutRemovedElement(oldSitemap); + } + }, () -> logger.debug("model {} sitemap {} not found", modelName, name)); + }); + + if (modelSitemaps.isEmpty()) { + sitemapsMap.remove(modelName); + } + } + + private @Nullable Sitemap mapSitemap(YamlSitemapDTO sitemapDTO) { + Sitemap sitemap = sitemapFactory.createSitemap(sitemapDTO.name); + sitemap.setLabel(sitemapDTO.label); + sitemap.setIcon(sitemapDTO.icon); + List widgets = sitemap.getWidgets(); + List widgetsDTO = sitemapDTO.widgets; + if (widgetsDTO != null) { + widgetsDTO.forEach(dto -> { + Widget w = mapWidget(dto, sitemap); + if (w != null) { + widgets.add(w); + } + }); + } + return sitemap; + } + + private @Nullable Widget mapWidget(YamlWidgetDTO widgetDTO, Parent parent) { + Widget widget = sitemapFactory.createWidget(widgetDTO.type, parent); + if (widget != null) { + widget.setItem(widgetDTO.item); + + String label = null; + if (widgetDTO.label instanceof YamlWidgetLabelDTO widgetLabel) { + if (widgetLabel.format != null) { + if (widgetLabel.label == null) { + label = "[%s]".formatted(widgetLabel.format); + } else { + label = "%s [%s]".formatted(widgetLabel.label, widgetLabel.format); + } + } else if (widgetLabel.label != null) { + label = widgetLabel.label; + } + addWidgetColorRules(widget.getLabelColor(), widgetLabel.labelColor); + addWidgetColorRules(widget.getValueColor(), widgetLabel.valueColor); + } + widget.setLabel(label); + + String icon = null; + Boolean staticIcon = null; + if (widgetDTO.icon instanceof YamlWidgetIconDTO widgetIcon) { + if (widgetIcon.name instanceof String name) { + icon = name; + staticIcon = widgetIcon.staticIcon; + } else { + addWidgetIconRules(widget.getIconRules(), widgetIcon.name); + } + addWidgetColorRules(widget.getIconColor(), widgetIcon.color); + } + widget.setIcon(icon); + widget.setStaticIcon(staticIcon); + + addWidgetVisibilityRules(widget.getVisibility(), widgetDTO.visibility); + + switch (widget) { + case Image imageWidget: + imageWidget.setUrl(widgetDTO.url); + imageWidget.setRefresh(widgetDTO.refresh); + break; + case Video videoWidget: + videoWidget.setUrl(widgetDTO.url); + videoWidget.setEncoding(widgetDTO.encoding); + break; + case Chart chartWidget: + chartWidget.setService(widgetDTO.service); + chartWidget.setRefresh(widgetDTO.refresh); + chartWidget.setPeriod(widgetDTO.period); + chartWidget.setLegend(widgetDTO.legend); + chartWidget.setForceAsItem(widgetDTO.forceAsItem); + chartWidget.setYAxisDecimalPattern(widgetDTO.yAxisDecimalPattern); + chartWidget.setInterpolation(widgetDTO.interpolation); + break; + case Webview webviewWidget: + webviewWidget.setHeight(widgetDTO.height); + webviewWidget.setUrl(widgetDTO.url); + break; + case Switch switchWidget: + addWidgetMappings(switchWidget.getMappings(), widgetDTO.mappings); + break; + case Mapview mapviewWidget: + mapviewWidget.setHeight(widgetDTO.height); + break; + case Slider sliderWidget: + sliderWidget.setMinValue(widgetDTO.min); + sliderWidget.setMaxValue(widgetDTO.max); + sliderWidget.setStep(widgetDTO.step); + sliderWidget.setSwitchEnabled(widgetDTO.switchSupport); + sliderWidget.setReleaseOnly(widgetDTO.releaseOnly); + break; + case Selection selectionWidget: + addWidgetMappings(selectionWidget.getMappings(), widgetDTO.mappings); + break; + case Input inputWidget: + inputWidget.setInputHint(widgetDTO.hint); + break; + case Setpoint setpointWidget: + setpointWidget.setMinValue(widgetDTO.min); + setpointWidget.setMaxValue(widgetDTO.max); + setpointWidget.setStep(widgetDTO.step); + break; + case Colortemperaturepicker colortemperaturepickerWidget: + colortemperaturepickerWidget.setMinValue(widgetDTO.min); + colortemperaturepickerWidget.setMaxValue(widgetDTO.max); + break; + case Button buttonWidget: + buttonWidget.setRow(widgetDTO.row); + buttonWidget.setColumn(widgetDTO.column); + buttonWidget.setStateless(widgetDTO.stateless); + buttonWidget.setCmd(widgetDTO.command); + buttonWidget.setReleaseCmd(widgetDTO.releaseCommand); + break; + case Default defaultWidget: + defaultWidget.setHeight(widgetDTO.height); + break; + default: + break; + } + + if (widgetDTO.widgets != null && widget instanceof LinkableWidget linkableWidget) { + List widgets = linkableWidget.getWidgets(); + widgetDTO.widgets.forEach(dto -> { + Widget w = mapWidget(dto, linkableWidget); + if (w != null) { + widgets.add(w); + } + }); + } + } + return widget; + } + + private void addWidgetMappings(List mappings, @Nullable List mappingsDTO) { + if (mappingsDTO != null) { + mappingsDTO.forEach(dto -> { + Mapping mapping = sitemapFactory.createMapping(); + mapping.setCmd(dto.command); + mapping.setReleaseCmd(dto.releaseCommand); + mapping.setLabel(dto.label); + mapping.setIcon(dto.icon); + mappings.add(mapping); + }); + } + } + + private void addWidgetIconRules(List iconRules, @Nullable Object iconRulesDTO) { + if (iconRulesDTO instanceof List rules) { + for (YamlIconRuleDTO dto : (List) rules) { + Rule rule = sitemapFactory.createRule(); + addRuleConditions(rule.getConditions(), dto.and, dto.item, dto.operator, dto.argument); + rule.setArgument(dto.value); + iconRules.add(rule); + } + } + } + + private void addWidgetColorRules(List colorRules, @Nullable Object colorRulesDTO) { + if (colorRulesDTO instanceof List rules) { + for (YamlColorRuleDTO dto : (List) rules) { + Rule rule = sitemapFactory.createRule(); + addRuleConditions(rule.getConditions(), dto.and, dto.item, dto.operator, dto.argument); + rule.setArgument(dto.value); + colorRules.add(rule); + } + } + } + + private void addWidgetVisibilityRules(List visibilityRules, + @Nullable List visibilityRulesDTO) { + if (visibilityRulesDTO != null) { + visibilityRulesDTO.forEach(dto -> { + Rule rule = sitemapFactory.createRule(); + addRuleConditions(rule.getConditions(), dto.and, dto.item, dto.operator, dto.argument); + visibilityRules.add(rule); + }); + } + } + + private void addRuleConditions(List conditions, @Nullable List conditionsDTO, + @Nullable String item, @Nullable String operator, @Nullable String argument) { + if (conditionsDTO != null) { + conditionsDTO.forEach(dto -> { + Condition condition = sitemapFactory.createCondition(); + condition.setItem(dto.item); + condition.setCondition(dto.operator); + condition.setValue(dto.argument); + conditions.add(condition); + }); + } else if (argument != null) { + Condition condition = sitemapFactory.createCondition(); + condition.setItem(item); + condition.setCondition(operator); + condition.setValue(argument); + conditions.add(condition); + } + } + + @Override + public @Nullable Sitemap getSitemap(String sitemapName) { + return null; + } + + @Override + public Set getSitemapNames() { + return Set.of(); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlVisibilityRuleDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlVisibilityRuleDTO.java new file mode 100644 index 00000000000..8af041bf0be --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlVisibilityRuleDTO.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +/** + * This is a data transfer object that is used to serialize visibility rules. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlVisibilityRuleDTO { + + public String item; + public String operator; + public String argument; + public List and; + + public YamlVisibilityRuleDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + boolean ok = true; + if (and != null && !and.isEmpty()) { + for (YamlConditionDTO condition : and) { + ok &= condition.isValid(errors, warnings); + } + } + return ok; + } + + @Override + public int hashCode() { + return Objects.hash(item, operator, argument, and); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlVisibilityRuleDTO other = (YamlVisibilityRuleDTO) obj; + return Objects.equals(item, other.item) && Objects.equals(operator, other.operator) + && Objects.equals(argument, other.argument) && Objects.equals(and, other.and); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetDTO.java new file mode 100644 index 00000000000..d605d895d69 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetDTO.java @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import static org.openhab.core.sitemap.internal.registry.SitemapFactoryImpl.*; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.items.ItemUtil; + +import com.fasterxml.jackson.annotation.JsonAlias; + +/** + * This is a data transfer object that is used to serialize widgets. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlWidgetDTO { + + private static final Set ALLOWED_HINTS = Set.of("text", "number", "date", "time", "datetime"); + private static final Set ALLOWED_INTERPOLATION = Set.of("linear", "step"); + private static final Set LINKABLE_WIDGETS = Set.of(FRAME, BUTTON_GRID, GROUP, TEXT, IMAGE); + + private static final Map> MANDATORY_FIELDS = new HashMap<>(); + private static final Map> OPTIONAL_FIELDS = new HashMap<>(); + + static { + MANDATORY_FIELDS.put(FRAME, Set.of()); + MANDATORY_FIELDS.put(BUTTON_GRID, Set.of()); + MANDATORY_FIELDS.put(BUTTON, Set.of("item", "row", "column", "command")); + MANDATORY_FIELDS.put(GROUP, Set.of("item")); + MANDATORY_FIELDS.put(TEXT, Set.of()); + MANDATORY_FIELDS.put(COLOR_PICKER, Set.of("item")); + MANDATORY_FIELDS.put(COLOR_TEMPERATURE_PICKER, Set.of("item")); + MANDATORY_FIELDS.put(INPUT, Set.of("item")); + MANDATORY_FIELDS.put(SWITCH, Set.of("item")); + MANDATORY_FIELDS.put(SELECTION, Set.of("item")); + MANDATORY_FIELDS.put(SETPOINT, Set.of("item")); + MANDATORY_FIELDS.put(SLIDER, Set.of("item")); + MANDATORY_FIELDS.put(IMAGE, Set.of()); + MANDATORY_FIELDS.put(CHART, Set.of("item", "period")); + MANDATORY_FIELDS.put(VIDEO, Set.of("url")); + MANDATORY_FIELDS.put(MAPVIEW, Set.of("item")); + MANDATORY_FIELDS.put(WEBVIEW, Set.of("url")); + MANDATORY_FIELDS.put(DEFAULT, Set.of("item")); + + OPTIONAL_FIELDS.put(FRAME, Set.of("item")); + OPTIONAL_FIELDS.put(BUTTON_GRID, Set.of()); + OPTIONAL_FIELDS.put(BUTTON, Set.of("releaseCommand", "stateless")); + OPTIONAL_FIELDS.put(GROUP, Set.of()); + OPTIONAL_FIELDS.put(TEXT, Set.of("item")); + OPTIONAL_FIELDS.put(COLOR_PICKER, Set.of()); + OPTIONAL_FIELDS.put(COLOR_TEMPERATURE_PICKER, Set.of("min", "max")); + OPTIONAL_FIELDS.put(INPUT, Set.of("hint")); + OPTIONAL_FIELDS.put(SWITCH, Set.of("mappings")); + OPTIONAL_FIELDS.put(SELECTION, Set.of("mappings")); + OPTIONAL_FIELDS.put(SETPOINT, Set.of("min", "max", "step")); + OPTIONAL_FIELDS.put(SLIDER, Set.of("switchSupport", "releaseOnly", "min", "max", "step")); + OPTIONAL_FIELDS.put(IMAGE, Set.of("item", "url", "refresh")); + OPTIONAL_FIELDS.put(CHART, + Set.of("item", "service", "legend", "forceAsItem", "yAxisDecimalPattern", "interpolation")); + OPTIONAL_FIELDS.put(VIDEO, Set.of("item", "encoding")); + OPTIONAL_FIELDS.put(MAPVIEW, Set.of("height")); + OPTIONAL_FIELDS.put(WEBVIEW, Set.of("item", "height")); + OPTIONAL_FIELDS.put(DEFAULT, Set.of("height")); + } + + public String type; + public String item; + public Object label; + public Object icon; + + // widget-specific attributes + public List mappings; + public Boolean switchSupport; + public Boolean releaseOnly; + public Integer height; + public BigDecimal min; + public BigDecimal max; + @JsonAlias({ "stepsize" }) + public BigDecimal step; + public String hint; + public String url; + public Integer refresh; + public String encoding; + public String service; + public String period; + public Boolean legend; + public Boolean forceAsItem; + public String yAxisDecimalPattern; + public String interpolation; + public Integer row; + public Integer column; + public String command; + public String releaseCommand; + public Boolean stateless; + + public List visibility; + + public List widgets; + + public YamlWidgetDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + if (type == null || type.isBlank()) { + addToList(errors, "\"type\" field missing while mandatory"); + return false; + } + if (!MANDATORY_FIELDS.keySet().contains(type)) { + addToList(errors, "invalid value \"%s\" for \"type\" field".formatted(type)); + return false; + } + + boolean ok = true; + + if (label instanceof YamlWidgetLabelDTO widgetLabel) { + ok &= widgetLabel.isValid(errors, warnings); + } + + if (icon instanceof YamlWidgetIconDTO widgetIcon) { + ok &= widgetIcon.isValid(errors, warnings); + } + + ok &= isValidField("item", item, errors, warnings); + ok &= isValidField("mappings", mappings, errors, warnings); + ok &= isValidField("switchSupport", switchSupport, errors, warnings); + ok &= isValidField("releaseOnly", releaseOnly, errors, warnings); + ok &= isValidField("height", height, errors, warnings); + ok &= isValidField("min", min, errors, warnings); + ok &= isValidField("max", max, errors, warnings); + ok &= isValidField("step", step, errors, warnings); + ok &= isValidField("hint", hint, errors, warnings); + ok &= isValidField("url", url, errors, warnings); + ok &= isValidField("refresh", refresh, errors, warnings); + ok &= isValidField("encoding", encoding, errors, warnings); + ok &= isValidField("service", service, errors, warnings); + ok &= isValidField("period", period, errors, warnings); + ok &= isValidField("legend", legend, errors, warnings); + ok &= isValidField("forceAsItem", forceAsItem, errors, warnings); + ok &= isValidField("yAxisDecimalPattern", yAxisDecimalPattern, errors, warnings); + ok &= isValidField("interpolation", interpolation, errors, warnings); + ok &= isValidField("row", row, errors, warnings); + ok &= isValidField("column", column, errors, warnings); + ok &= isValidField("command", command, errors, warnings); + ok &= isValidField("releaseCommand", releaseCommand, errors, warnings); + ok &= isValidField("stateless", stateless, errors, warnings); + + if (mappings != null && !mappings.isEmpty() && (SWITCH.equals(type) || SELECTION.equals(type))) { + for (YamlMappingDTO mapping : mappings) { + ok &= mapping.isValid(errors, warnings); + } + } + + if (visibility != null && !visibility.isEmpty()) { + List ruleErrors = new ArrayList<>(); + List ruleWarnings = new ArrayList<>(); + for (YamlVisibilityRuleDTO rule : visibility) { + ok &= rule.isValid(ruleErrors, ruleWarnings); + } + ruleErrors.forEach(error -> { + addToList(errors, "visibility: %s".formatted(error)); + }); + ruleWarnings.forEach(warning -> { + addToList(warnings, "visibility: %s".formatted(warning)); + }); + } + + if (widgets != null && !widgets.isEmpty()) { + if (!LINKABLE_WIDGETS.contains(type)) { + addToList(errors, "unexpected sub-widgets"); + ok = false; + } else { + for (int i = 0; i < widgets.size(); i++) { + String id = "%d/%d".formatted(i + 1, widgets.size()); + YamlWidgetDTO widget = widgets.get(i); + String wType = widget.type == null ? "?" : widget.type; + List widgetErrors = new ArrayList<>(); + List widgetWarnings = new ArrayList<>(); + ok &= widget.isValid(widgetErrors, widgetWarnings); + widgetErrors.forEach(error -> { + addToList(errors, "widget %s of type %s: %s".formatted(id, wType, error)); + }); + widgetWarnings.forEach(warning -> { + addToList(warnings, "widget %s of type %s: %s".formatted(id, wType, warning)); + }); + } + } + } + + return ok; + } + + private boolean isValidField(String field, @Nullable Object value, @NonNull List<@NonNull String> errors, + @NonNull List<@NonNull String> warnings) { + boolean ok = true; + Set mandatory_fields = Objects.requireNonNull(MANDATORY_FIELDS.get(type)); + Set optional_fields = Objects.requireNonNull(OPTIONAL_FIELDS.get(type)); + if (value == null && mandatory_fields.contains(field)) { + addToList(errors, "\"%s\" field missing while mandatory".formatted(field)); + ok = false; + } else if (value != null && !mandatory_fields.contains(field) && !optional_fields.contains(field)) { + addToList(warnings, "unexpected \"%s\" field is ignored".formatted(field)); + } else if ("item".equals(field) && value instanceof String item && !ItemUtil.isValidItemName(item)) { + addToList(errors, + "invalid value \"%s\" for \"%s\" field; it must begin with a letter or underscore followed by alphanumeric characters and underscores, and must not contain any other symbols" + .formatted(item, field)); + ok = false; + } else if ("height".equals(field) && value instanceof Integer height && height <= 0) { + addToList(height < 0 ? errors : warnings, + "invalid value \"%d\" for \"%s\" field; value must be greater than 0".formatted(height, field)); + if (height < 0) { + ok = false; + } + } else if ("refresh".equals(field) && value instanceof Integer refresh && refresh <= 0) { + addToList(refresh < 0 ? errors : warnings, + "invalid value \"%d\" for \"%s\" field; value must be greater than 0".formatted(refresh, field)); + if (refresh < 0) { + ok = false; + } + } else if ("row".equals(field) && value instanceof Integer row && row <= 0) { + addToList(row < 0 ? errors : warnings, + "invalid value \"%d\" for \"%s\" field; value must be greater than 0".formatted(row, field)); + if (row < 0) { + ok = false; + } + } else if ("column".equals(field) && value instanceof Integer column && column <= 0) { + addToList(column < 0 ? errors : warnings, + "invalid value \"%d\" for \"%s\" field; value must be greater than 0".formatted(column, field)); + if (column < 0) { + ok = false; + } + } else if ("step".equals(field) && value instanceof BigDecimal step && step.doubleValue() <= 0) { + addToList(step.doubleValue() < 0 ? errors : warnings, + "invalid value \"%f\" for \"%s\" field; value must be greater than 0".formatted(step.doubleValue(), + field)); + if (step.doubleValue() < 0) { + ok = false; + } + } else if ("hint".equals(field) && value instanceof String hint && !ALLOWED_HINTS.contains(hint)) { + addToList(warnings, "invalid value \"%s\" for \"%s\" field".formatted(hint, field)); + } else if ("interpolation".equals(field) && value instanceof String interpolation + && !ALLOWED_INTERPOLATION.contains(interpolation)) { + addToList(warnings, "invalid value \"%s\" for \"%s\" field".formatted(interpolation, field)); + } + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + @Override + public int hashCode() { + return Objects.hash(type, item, label, icon, mappings, switchSupport, releaseOnly, height, min, max, step, hint, + url, refresh, encoding, service, period, legend, forceAsItem, yAxisDecimalPattern, interpolation, row, + column, command, releaseCommand, stateless, visibility, widgets); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlWidgetDTO other = (YamlWidgetDTO) obj; + return Objects.equals(type, other.type) && Objects.equals(item, other.item) + && Objects.equals(label, other.label) && Objects.equals(icon, other.icon) + && Objects.equals(mappings, other.mappings) && Objects.equals(switchSupport, other.switchSupport) + && Objects.equals(releaseOnly, other.releaseOnly) && Objects.equals(height, other.height) + && Objects.equals(min, other.min) && Objects.equals(max, other.max) && Objects.equals(url, other.url) + && Objects.equals(refresh, other.refresh) && Objects.equals(encoding, other.encoding) + && Objects.equals(service, other.service) && Objects.equals(period, other.period) + && Objects.equals(legend, other.legend) && Objects.equals(forceAsItem, other.forceAsItem) + && Objects.equals(yAxisDecimalPattern, other.yAxisDecimalPattern) + && Objects.equals(interpolation, other.interpolation) && Objects.equals(row, other.row) + && Objects.equals(column, other.column) && Objects.equals(command, other.command) + && Objects.equals(releaseCommand, other.releaseCommand) && Objects.equals(stateless, other.stateless) + && Objects.equals(visibility, other.visibility) && Objects.equals(widgets, other.widgets); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetIconDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetIconDTO.java new file mode 100644 index 00000000000..0c352bff08d --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetIconDTO.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * This is a data transfer object that is used to serialize a widget icon. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlWidgetIconDTO { + + public Object name; + @JsonProperty("static") + @JsonAlias("staticIcon") + public Boolean staticIcon; + public Object color; + + public YamlWidgetIconDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + boolean ok = true; + + if (name instanceof String nameStr) { + if (!YamlElementUtils.isValidIcon(nameStr)) { + addToList(errors, + "invalid icon value \"%s\" for \"name\" field; it must contain a maximum of 3 segments separated by a colon, each segment matching pattern [a-zA-Z0-9_][a-zA-Z0-9_-]*" + .formatted(nameStr)); + ok = false; + } + } else if (name instanceof List rules) { + List ruleErrors = new ArrayList<>(); + List ruleWarnings = new ArrayList<>(); + for (YamlIconRuleDTO rule : (List) rules) { + ok &= rule.isValid(ruleErrors, ruleWarnings); + } + ruleErrors.forEach(error -> { + addToList(errors, "icon rules: %s".formatted(error)); + }); + ruleWarnings.forEach(warning -> { + addToList(warnings, "icon rules: %s".formatted(warning)); + }); + } + + if (color instanceof List rules) { + List ruleErrors = new ArrayList<>(); + List ruleWarnings = new ArrayList<>(); + for (YamlColorRuleDTO rule : (List) rules) { + ok &= rule.isValid(ruleErrors, ruleWarnings); + } + ruleErrors.forEach(error -> { + addToList(errors, "icon color: %s".formatted(error)); + }); + ruleWarnings.forEach(warning -> { + addToList(warnings, "icon color: %s".formatted(warning)); + }); + } + + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + @Override + public int hashCode() { + return Objects.hash(name, staticIcon, color); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlWidgetIconDTO other = (YamlWidgetIconDTO) obj; + return Objects.equals(name, other.name) && Objects.equals(staticIcon, other.staticIcon) + && Objects.equals(color, other.color); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetLabelDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetLabelDTO.java new file mode 100644 index 00000000000..25914637541 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/YamlWidgetLabelDTO.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +/** + * This is a data transfer object that is used to serialize a widget label. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlWidgetLabelDTO { + + public String label; + public String format; + public Object labelColor; + public Object valueColor; + + public YamlWidgetLabelDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + boolean ok = true; + + if (labelColor instanceof List rules) { + List ruleErrors = new ArrayList<>(); + List ruleWarnings = new ArrayList<>(); + for (YamlColorRuleDTO rule : (List) rules) { + ok &= rule.isValid(ruleErrors, ruleWarnings); + } + ruleErrors.forEach(error -> { + addToList(errors, "label color: %s".formatted(error)); + }); + ruleWarnings.forEach(warning -> { + addToList(warnings, "label color: %s".formatted(warning)); + }); + } + + if (valueColor instanceof List rules) { + List ruleErrors = new ArrayList<>(); + List ruleWarnings = new ArrayList<>(); + for (YamlColorRuleDTO rule : (List) rules) { + ok &= rule.isValid(ruleErrors, ruleWarnings); + } + ruleErrors.forEach(error -> { + addToList(errors, "value color: %s".formatted(error)); + }); + ruleWarnings.forEach(warning -> { + addToList(warnings, "value color: %s".formatted(warning)); + }); + } + + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + @Override + public int hashCode() { + return Objects.hash(label, format, labelColor, valueColor); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlWidgetLabelDTO other = (YamlWidgetLabelDTO) obj; + return Objects.equals(label, other.label) && Objects.equals(format, other.format) + && Objects.equals(labelColor, other.labelColor) && Objects.equals(valueColor, other.valueColor); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/fileconverter/YamlSitemapConverter.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/fileconverter/YamlSitemapConverter.java new file mode 100644 index 00000000000..d362f510275 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/sitemaps/fileconverter/YamlSitemapConverter.java @@ -0,0 +1,401 @@ +/* + * Copyright (c) 2010-2026 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.internal.sitemaps.fileconverter; + +import static org.openhab.core.sitemap.internal.registry.SitemapFactoryImpl.BUTTON; + +import java.io.ByteArrayInputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlModelRepository; +import org.openhab.core.model.yaml.internal.sitemaps.YamlColorRuleDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlConditionDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlIconRuleDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlMappingDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlSitemapDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlSitemapProvider; +import org.openhab.core.model.yaml.internal.sitemaps.YamlVisibilityRuleDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlWidgetDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlWidgetIconDTO; +import org.openhab.core.model.yaml.internal.sitemaps.YamlWidgetLabelDTO; +import org.openhab.core.sitemap.Button; +import org.openhab.core.sitemap.Buttongrid; +import org.openhab.core.sitemap.Chart; +import org.openhab.core.sitemap.Colortemperaturepicker; +import org.openhab.core.sitemap.Condition; +import org.openhab.core.sitemap.Default; +import org.openhab.core.sitemap.Image; +import org.openhab.core.sitemap.Input; +import org.openhab.core.sitemap.LinkableWidget; +import org.openhab.core.sitemap.Mapping; +import org.openhab.core.sitemap.Mapview; +import org.openhab.core.sitemap.Rule; +import org.openhab.core.sitemap.Selection; +import org.openhab.core.sitemap.Setpoint; +import org.openhab.core.sitemap.Sitemap; +import org.openhab.core.sitemap.Slider; +import org.openhab.core.sitemap.Switch; +import org.openhab.core.sitemap.Video; +import org.openhab.core.sitemap.Webview; +import org.openhab.core.sitemap.Widget; +import org.openhab.core.sitemap.fileconverter.SitemapParser; +import org.openhab.core.sitemap.fileconverter.SitemapSerializer; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * {@link YamlSitemapConverter} is the YAML converter for {@link Sitemap} objects. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = { SitemapSerializer.class, SitemapParser.class }) +public class YamlSitemapConverter implements SitemapSerializer, SitemapParser { + + private final YamlModelRepository modelRepository; + private final YamlSitemapProvider sitemapProvider; + + @Activate + public YamlSitemapConverter(final @Reference YamlModelRepository modelRepository, + final @Reference YamlSitemapProvider sitemapProvider) { + this.modelRepository = modelRepository; + this.sitemapProvider = sitemapProvider; + } + + @Override + public String getGeneratedFormat() { + return "YAML"; + } + + @Override + public void setSitemapsToBeSerialized(String id, List sitemaps) { + List elements = new ArrayList<>(); + sitemaps.forEach(sitemap -> { + elements.add(buildSitemapDTO(sitemap)); + }); + modelRepository.addElementsToBeGenerated(id, elements); + } + + @Override + public void generateFormat(String id, OutputStream out) { + modelRepository.generateFileFormat(id, out); + } + + private YamlSitemapDTO buildSitemapDTO(Sitemap sitemap) { + YamlSitemapDTO dto = new YamlSitemapDTO(); + dto.name = sitemap.getName(); + dto.label = sitemap.getLabel(); + dto.icon = sitemap.getIcon(); + + List widgets = new ArrayList<>(); + sitemap.getWidgets().forEach(w -> { + widgets.add(buildWidgetDTO(w)); + }); + dto.widgets = widgets.isEmpty() ? null : widgets; + + return dto; + } + + private YamlWidgetDTO buildWidgetDTO(Widget widget) { + YamlWidgetDTO dto = new YamlWidgetDTO(); + dto.type = widget.getWidgetType(); + dto.item = widget.getItem(); + + List labelColorRules = buidColorRules(widget.getLabelColor()); + List valueColorRules = buidColorRules(widget.getValueColor()); + if (widget.getLabel() != null || !labelColorRules.isEmpty() || !valueColorRules.isEmpty()) { + String label = widget.getLabel(); + String format = null; + if (label != null) { + int idx = label.indexOf("["); + if (idx >= 0) { + format = label.substring(idx + 1, label.length() - 1).trim(); + label = label.substring(0, idx).trim(); + } + } + if (format != null || !labelColorRules.isEmpty() || !valueColorRules.isEmpty()) { + YamlWidgetLabelDTO widgetLabel = new YamlWidgetLabelDTO(); + widgetLabel.label = label; + widgetLabel.format = format; + if (!labelColorRules.isEmpty()) { + YamlColorRuleDTO first = labelColorRules.getFirst(); + widgetLabel.labelColor = labelColorRules.size() == 1 && first.and == null && first.item == null + && first.operator == null && first.argument == null && first.value != null + ? new String(first.value) + : labelColorRules; + } + if (!valueColorRules.isEmpty()) { + YamlColorRuleDTO first = valueColorRules.getFirst(); + widgetLabel.valueColor = valueColorRules.size() == 1 && first.and == null && first.item == null + && first.operator == null && first.argument == null && first.value != null + ? new String(first.value) + : valueColorRules; + } + dto.label = widgetLabel; + } else { + dto.label = label; + } + } + + List iconRules = buildIconRules(widget.getIconRules()); + List iconColorRules = buidColorRules(widget.getIconColor()); + if (widget.getIcon() != null || !iconRules.isEmpty() || !iconColorRules.isEmpty()) { + if (widget.isStaticIcon() || !iconRules.isEmpty() || !iconColorRules.isEmpty()) { + YamlWidgetIconDTO widgetIcon = new YamlWidgetIconDTO(); + widgetIcon.name = widget.getIcon(); + if (widget.isStaticIcon()) { + widgetIcon.staticIcon = true; + } + if (!iconRules.isEmpty()) { + YamlIconRuleDTO first = iconRules.getFirst(); + widgetIcon.name = iconRules.size() == 1 && first.and == null && first.item == null + && first.operator == null && first.argument == null && first.value != null + ? new String(first.value) + : iconRules; + } + if (!iconColorRules.isEmpty()) { + YamlColorRuleDTO first = iconColorRules.getFirst(); + widgetIcon.color = iconColorRules.size() == 1 && first.and == null && first.item == null + && first.operator == null && first.argument == null && first.value != null + ? new String(first.value) + : iconColorRules; + } + dto.icon = widgetIcon; + } else { + dto.icon = widget.getIcon(); + } + } + + List visibilityRules = buildVisibilityRules(widget.getVisibility()); + dto.visibility = visibilityRules.isEmpty() ? null : visibilityRules; + + switch (widget) { + case Switch switchWidget -> { + List mappings = buildMappings(switchWidget.getMappings()); + dto.mappings = mappings.isEmpty() ? null : mappings; + } + case Button buttonWidget -> { + dto.row = buttonWidget.getRow(); + dto.column = buttonWidget.getColumn(); + dto.command = buttonWidget.getCmd(); + dto.releaseCommand = buttonWidget.getReleaseCmd(); + if (buttonWidget.isStateless()) { + dto.stateless = true; + } + } + case Selection selectionWidget -> { + List mappings = buildMappings(selectionWidget.getMappings()); + dto.mappings = mappings.isEmpty() ? null : mappings; + } + case Setpoint setpointWidget -> { + dto.min = setpointWidget.getMinValue(); + dto.max = setpointWidget.getMaxValue(); + dto.step = setpointWidget.getStep(); + } + case Slider sliderWidget -> { + dto.min = sliderWidget.getMinValue(); + dto.max = sliderWidget.getMaxValue(); + dto.step = sliderWidget.getStep(); + if (sliderWidget.isSwitchEnabled()) { + dto.switchSupport = true; + } + if (sliderWidget.isReleaseOnly()) { + dto.releaseOnly = true; + } + } + case Colortemperaturepicker colortemperaturepickerWidget -> { + dto.min = colortemperaturepickerWidget.getMinValue(); + dto.max = colortemperaturepickerWidget.getMaxValue(); + } + case Input inputWidget -> { + dto.hint = inputWidget.getInputHint(); + } + case Webview webviewWidget -> { + dto.url = webviewWidget.getUrl(); + if (webviewWidget.getHeight() > 0) { + dto.height = webviewWidget.getHeight(); + } + } + case Mapview mapviewWidget -> { + if (mapviewWidget.getHeight() > 0) { + dto.height = mapviewWidget.getHeight(); + } + } + case Image imageWidget -> { + dto.url = imageWidget.getUrl(); + if (imageWidget.getRefresh() > 0) { + dto.refresh = imageWidget.getRefresh(); + } + } + case Video videoWidget -> { + dto.url = videoWidget.getUrl(); + dto.encoding = videoWidget.getEncoding(); + } + case Chart chartWidget -> { + if (chartWidget.getRefresh() > 0) { + dto.refresh = chartWidget.getRefresh(); + } + dto.period = chartWidget.getPeriod(); + dto.service = chartWidget.getService(); + dto.legend = chartWidget.hasLegend(); + if (chartWidget.forceAsItem()) { + dto.forceAsItem = true; + } + dto.yAxisDecimalPattern = chartWidget.getYAxisDecimalPattern(); + dto.interpolation = chartWidget.getInterpolation(); + } + case Default defaultWidget -> { + if (defaultWidget.getHeight() > 0) { + dto.height = defaultWidget.getHeight(); + } + } + default -> { + } + } + + if (widget instanceof LinkableWidget linkableWidget) { + List widgets = new ArrayList<>(); + if (linkableWidget instanceof Buttongrid buttongridWidget) { + buttongridWidget.getButtons().forEach(button -> { + YamlWidgetDTO w = new YamlWidgetDTO(); + w.type = BUTTON; + w.item = buttongridWidget.getItem(); + w.label = button.getLabel(); + w.icon = button.getIcon(); + w.row = button.getRow(); + w.column = button.getColumn(); + w.command = button.getCmd(); + widgets.add(w); + }); + } + linkableWidget.getWidgets().forEach(w -> { + widgets.add(buildWidgetDTO(w)); + }); + dto.widgets = widgets.isEmpty() ? null : widgets; + } + + return dto; + } + + private List buildMappings(List mappings) { + List dtos = new ArrayList<>(); + mappings.forEach(mapping -> { + YamlMappingDTO dto = new YamlMappingDTO(); + dto.command = mapping.getCmd(); + dto.releaseCommand = mapping.getReleaseCmd(); + dto.label = mapping.getLabel(); + dto.icon = mapping.getIcon(); + dtos.add(dto); + }); + return dtos; + } + + private List buildIconRules(List rules) { + List dtos = new ArrayList<>(); + rules.forEach(rule -> { + YamlIconRuleDTO dto = new YamlIconRuleDTO(); + List conditions = rule.getConditions(); + if (!conditions.isEmpty()) { + if (conditions.size() > 1) { + dto.and = buildConditions(conditions); + } else { + dto.item = conditions.getFirst().getItem(); + dto.operator = conditions.getFirst().getCondition(); + dto.argument = conditions.getFirst().getValue(); + } + } + dto.value = rule.getArgument(); + dtos.add(dto); + }); + return dtos; + } + + private List buidColorRules(List rules) { + List dtos = new ArrayList<>(); + rules.forEach(rule -> { + YamlColorRuleDTO dto = new YamlColorRuleDTO(); + List conditions = rule.getConditions(); + if (!conditions.isEmpty()) { + if (conditions.size() > 1) { + dto.and = buildConditions(conditions); + } else { + dto.item = conditions.getFirst().getItem(); + dto.operator = conditions.getFirst().getCondition(); + dto.argument = conditions.getFirst().getValue(); + } + } + dto.value = rule.getArgument(); + dtos.add(dto); + }); + return dtos; + } + + private List buildVisibilityRules(List rules) { + List dtos = new ArrayList<>(); + rules.forEach(rule -> { + YamlVisibilityRuleDTO dto = new YamlVisibilityRuleDTO(); + List conditions = rule.getConditions(); + if (!conditions.isEmpty()) { + if (conditions.size() > 1) { + dto.and = buildConditions(conditions); + } else { + dto.item = conditions.getFirst().getItem(); + dto.operator = conditions.getFirst().getCondition(); + dto.argument = conditions.getFirst().getValue(); + } + } + dtos.add(dto); + }); + return dtos; + } + + private List buildConditions(List conditions) { + List dtos = new ArrayList<>(); + conditions.forEach(condition -> { + YamlConditionDTO dto = new YamlConditionDTO(); + dto.item = condition.getItem(); + dto.operator = condition.getCondition(); + dto.argument = condition.getValue(); + dtos.add(dto); + }); + return dtos; + } + + @Override + public String getParserFormat() { + return "YAML"; + } + + @Override + public @Nullable String startParsingFormat(String syntax, List errors, List warnings) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(syntax.getBytes()); + return modelRepository.createIsolatedModel(inputStream, errors, warnings); + } + + @Override + public Collection getParsedObjects(String modelName) { + return sitemapProvider.getAllFromModel(modelName); + } + + @Override + public void finishParsingFormat(String modelName) { + modelRepository.removeIsolatedModel(modelName); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/YamlElementUtils.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/YamlElementUtils.java index fa3a6b1bfa4..6a9e615e879 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/YamlElementUtils.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/YamlElementUtils.java @@ -12,8 +12,11 @@ */ package org.openhab.core.model.yaml.internal.util; +import java.util.regex.Pattern; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.AbstractUID; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.types.util.UnitUtils; import org.openhab.core.util.StringUtils; @@ -26,6 +29,8 @@ @NonNullByDefault public class YamlElementUtils { + private static final Pattern ICON_SEGMENT_PATTERN = Pattern.compile("[a-zA-Z0-9_][a-zA-Z0-9_-]*"); + public static @Nullable String getAdjustedItemType(@Nullable String type) { return type == null ? null : StringUtils.capitalize(type); } @@ -60,4 +65,18 @@ public static boolean isValidItemDimension(@Nullable String dimension) { String adjustedDimension = getAdjustedItemDimension(dimension); return adjustedType != null ? adjustedType + (adjustedDimension == null ? "" : ":" + adjustedDimension) : null; } + + public static boolean isValidIcon(String icon) { + String[] segments = icon.split(AbstractUID.SEPARATOR); + int nb = segments.length; + if (nb > 3) { + return false; + } + for (int i = 0; i < nb; i++) { + if (!ICON_SEGMENT_PATTERN.matcher(segments[i]).matches()) { + return false; + } + } + return true; + } } diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/AbstractSitemapDTO.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/AbstractSitemapDTO.java new file mode 100644 index 00000000000..9caec670d97 --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/AbstractSitemapDTO.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.dto; + +/** + * This is a data transfer object that is used to serialize sitemaps. + * + * @author Kai Kreuzer - Initial contribution + * @author Chris Jackson - Initial contribution + * @author Mark Herwege - Created as abstract base class for SitemapDefinitionDTO classes + */ +public abstract class AbstractSitemapDTO { + + public String name; + public String icon; + public String label; + + public AbstractSitemapDTO() { + } +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/AbstractWidgetDTO.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/AbstractWidgetDTO.java new file mode 100644 index 00000000000..8cf68c72d12 --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/AbstractWidgetDTO.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.dto; + +import java.math.BigDecimal; +import java.util.List; + +/** + * This is a data transfer object that is used to serialize widgets. + * + * @author Kai Kreuzer - Initial contribution + * @author Chris Jackson - Initial contribution + * @author Laurent Garnier - New field iconcolor + * @author Mark Herwege - New fields pattern, unit + * @author Laurent Garnier - New field columns + * @author Danny Baumann - New field labelSource + * @author Laurent Garnier - Remove field columns + * @author Laurent Garnier - New fields row, column, command, releaseCommand and stateless for Button element + * @author Mark Herwege - Created as abstract base class for WidgetDefinitionDTO classes + */ +public abstract class AbstractWidgetDTO { + + public String type; + public String label; + public String icon; + /** + * staticIcon is a boolean indicating if the widget state must be ignored when requesting the icon. + * It is set to true when the widget has either the staticIcon property set or the icon property set + * with conditional rules. + */ + public Boolean staticIcon; + + // widget-specific attributes + public List mappings; + public Boolean switchSupport; + public Boolean releaseOnly; + public Integer refresh; + public Integer height; + public BigDecimal minValue; + public BigDecimal maxValue; + public BigDecimal step; + public String inputHint; + public String url; + public String encoding; + public String service; + public String period; + public String yAxisDecimalPattern; + public String interpolation; + public Boolean legend; + public Boolean forceAsItem; + public Integer row; + public Integer column; + public String command; + public String releaseCommand; + public Boolean stateless; + + public AbstractWidgetDTO() { + } +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/ButtonDefinitionDTO.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/ButtonDefinitionDTO.java new file mode 100644 index 00000000000..822d9cd8179 --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/ButtonDefinitionDTO.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * This is a data transfer object that is used to serialize button definitions. + * + * @author Mark Herwege - Initial contribution + */ +@Schema(name = "ButtonDefinition") +public class ButtonDefinitionDTO { + + public Integer row; + public Integer column; + public String command; + public String label; + public String icon; + + public ButtonDefinitionDTO() { + } +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/ConditionDTO.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/ConditionDTO.java new file mode 100644 index 00000000000..ff9952c5d3e --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/ConditionDTO.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * This is a data transfer object that is used to serialize rule conditions. + * + * @author Mark Herwege - Initial contribution + */ +@Schema(name = "Condition") +public class ConditionDTO { + + public String item; + public String condition; + public String value; + + public ConditionDTO() { + } +} diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/MappingDTO.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/MappingDTO.java similarity index 95% rename from bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/MappingDTO.java rename to bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/MappingDTO.java index 37c2e7dbba7..2266728d1c5 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/MappingDTO.java +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/MappingDTO.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.core.io.rest.sitemap.internal; +package org.openhab.core.sitemap.dto; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/RuleDTO.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/RuleDTO.java new file mode 100644 index 00000000000..1039a473779 --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/RuleDTO.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * This is a data transfer object that is used to serialize rules. + * + * @author Mark Herwege - Initial contribution + */ +@Schema(name = "Rule") +public class RuleDTO { + + public List conditions; + public String argument; + + public RuleDTO() { + } +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/SitemapDTOMapper.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/SitemapDTOMapper.java new file mode 100644 index 00000000000..5b68677c4af --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/SitemapDTOMapper.java @@ -0,0 +1,428 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.dto; + +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.sitemap.Button; +import org.openhab.core.sitemap.ButtonDefinition; +import org.openhab.core.sitemap.Buttongrid; +import org.openhab.core.sitemap.Chart; +import org.openhab.core.sitemap.Colortemperaturepicker; +import org.openhab.core.sitemap.Condition; +import org.openhab.core.sitemap.Default; +import org.openhab.core.sitemap.Image; +import org.openhab.core.sitemap.Input; +import org.openhab.core.sitemap.LinkableWidget; +import org.openhab.core.sitemap.Mapping; +import org.openhab.core.sitemap.Mapview; +import org.openhab.core.sitemap.Parent; +import org.openhab.core.sitemap.Rule; +import org.openhab.core.sitemap.Selection; +import org.openhab.core.sitemap.Setpoint; +import org.openhab.core.sitemap.Sitemap; +import org.openhab.core.sitemap.Slider; +import org.openhab.core.sitemap.Switch; +import org.openhab.core.sitemap.Video; +import org.openhab.core.sitemap.Webview; +import org.openhab.core.sitemap.Widget; +import org.openhab.core.sitemap.registry.SitemapFactory; + +/** + * The {@link SitemapDTOMapper} is a utility class to map sitemaps into data transfer objects (DTO). + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +public class SitemapDTOMapper { + + /** + * Maps sitemaps into sitemap data transfer object (DTO). + * + * @param sitemap the sitemap + * @return the sitemap DTO object + */ + public static SitemapDefinitionDTO map(Sitemap sitemap) { + SitemapDefinitionDTO sitemapDTO = new SitemapDefinitionDTO(); + sitemapDTO.name = sitemap.getName(); + sitemapDTO.label = sitemap.getLabel(); + sitemapDTO.icon = sitemap.getIcon(); + + List widgets = sitemap.getWidgets(); + if (!widgets.isEmpty()) { + sitemapDTO.widgets = widgets.stream().map(SitemapDTOMapper::map).toList(); + } + return sitemapDTO; + } + + private static WidgetDefinitionDTO map(Widget widget) { + WidgetDefinitionDTO widgetDTO = new WidgetDefinitionDTO(); + widgetDTO.type = widget.getWidgetType(); + widgetDTO.item = widget.getItem(); + widgetDTO.label = widget.getLabel(); + widgetDTO.icon = widget.getIcon(); + if (widget.isStaticIcon()) { + widgetDTO.staticIcon = true; + } + + List iconRules = widget.getIconRules(); + if (!iconRules.isEmpty()) { + widgetDTO.iconRules = iconRules.stream().map(SitemapDTOMapper::map).toList(); + } + List visibilityRules = widget.getVisibility(); + if (!visibilityRules.isEmpty()) { + widgetDTO.visibilityRules = visibilityRules.stream().map(SitemapDTOMapper::map).toList(); + } + List labelColorRules = widget.getLabelColor(); + if (!labelColorRules.isEmpty()) { + widgetDTO.labelColorRules = labelColorRules.stream().map(SitemapDTOMapper::map).toList(); + } + List valueColorRules = widget.getValueColor(); + if (!valueColorRules.isEmpty()) { + widgetDTO.valueColorRules = valueColorRules.stream().map(SitemapDTOMapper::map).toList(); + } + List iconColorRules = widget.getIconColor(); + if (!iconColorRules.isEmpty()) { + widgetDTO.iconColorRules = iconColorRules.stream().map(SitemapDTOMapper::map).toList(); + } + + switch (widget) { + case Switch switchWidget -> { + List mappings = switchWidget.getMappings(); + if (!mappings.isEmpty()) { + widgetDTO.mappings = mappings.stream().map(SitemapDTOMapper::map).toList(); + } + } + case Buttongrid buttongridWidget -> { + List buttons = buttongridWidget.getButtons(); + if (!buttons.isEmpty()) { + widgetDTO.buttons = buttons.stream().map(SitemapDTOMapper::map).toList(); + } + } + case Button buttonWidget -> { + widgetDTO.row = buttonWidget.getRow(); + widgetDTO.column = buttonWidget.getColumn(); + if (buttonWidget.isStateless()) { + widgetDTO.stateless = true; + } + widgetDTO.command = buttonWidget.getCmd(); + widgetDTO.releaseCommand = buttonWidget.getReleaseCmd(); + } + case Selection selectionWidget -> { + List mappings = selectionWidget.getMappings(); + if (!mappings.isEmpty()) { + widgetDTO.mappings = mappings.stream().map(SitemapDTOMapper::map).toList(); + } + } + case Setpoint setpointWidget -> { + widgetDTO.minValue = setpointWidget.getMinValue(); + widgetDTO.maxValue = setpointWidget.getMaxValue(); + widgetDTO.step = setpointWidget.getStep(); + } + case Slider sliderWidget -> { + widgetDTO.minValue = sliderWidget.getMinValue(); + widgetDTO.maxValue = sliderWidget.getMaxValue(); + widgetDTO.step = sliderWidget.getStep(); + if (sliderWidget.isSwitchEnabled()) { + widgetDTO.switchSupport = true; + } + if (sliderWidget.isReleaseOnly()) { + widgetDTO.releaseOnly = true; + } + } + case Colortemperaturepicker colortemperaturepickerWidget -> { + widgetDTO.minValue = colortemperaturepickerWidget.getMinValue(); + widgetDTO.maxValue = colortemperaturepickerWidget.getMaxValue(); + } + case Input inputWidget -> { + widgetDTO.inputHint = inputWidget.getInputHint(); + } + case Webview webviewWidget -> { + widgetDTO.url = webviewWidget.getUrl(); + int height = webviewWidget.getHeight(); + if (height > 0) { + widgetDTO.height = height; + } + } + case Mapview mapviewWidget -> { + int height = mapviewWidget.getHeight(); + if (height > 0) { + widgetDTO.height = height; + } + } + case Image imageWidget -> { + widgetDTO.url = imageWidget.getUrl(); + int refresh = imageWidget.getRefresh(); + if (refresh > 0) { + widgetDTO.refresh = refresh; + } + } + case Video videoWidget -> { + widgetDTO.url = videoWidget.getUrl(); + widgetDTO.encoding = videoWidget.getEncoding(); + } + case Chart chartWidget -> { + int refresh = chartWidget.getRefresh(); + if (refresh > 0) { + widgetDTO.refresh = refresh; + } + widgetDTO.period = chartWidget.getPeriod(); + widgetDTO.service = chartWidget.getService(); + widgetDTO.legend = chartWidget.hasLegend(); + if (chartWidget.forceAsItem()) { + widgetDTO.forceAsItem = true; + } + widgetDTO.yAxisDecimalPattern = chartWidget.getYAxisDecimalPattern(); + widgetDTO.interpolation = chartWidget.getInterpolation(); + } + case Default defaultWidget -> { + int height = defaultWidget.getHeight(); + if (height > 0) { + widgetDTO.height = height; + } + } + default -> { + // nothing to do + } + } + + if (widget instanceof LinkableWidget linkableWidget) { + List widgets = linkableWidget.getWidgets(); + if (!widgets.isEmpty()) { + widgetDTO.widgets = widgets.stream().map(SitemapDTOMapper::map).toList(); + } + } + return widgetDTO; + } + + private static RuleDTO map(Rule rule) { + RuleDTO ruleDTO = new RuleDTO(); + List conditions = rule.getConditions(); + if (!conditions.isEmpty()) { + ruleDTO.conditions = conditions.stream().map(SitemapDTOMapper::map).toList(); + } + ruleDTO.argument = rule.getArgument(); + return ruleDTO; + } + + private static ConditionDTO map(Condition condition) { + ConditionDTO conditionDTO = new ConditionDTO(); + conditionDTO.item = condition.getItem(); + conditionDTO.condition = condition.getCondition(); + conditionDTO.value = condition.getValue(); + return conditionDTO; + } + + private static MappingDTO map(Mapping mapping) { + MappingDTO mappingDTO = new MappingDTO(); + mappingDTO.label = mapping.getLabel(); + mappingDTO.icon = mapping.getIcon(); + mappingDTO.command = mapping.getCmd(); + mappingDTO.releaseCommand = mapping.getReleaseCmd(); + return mappingDTO; + } + + private static ButtonDefinitionDTO map(ButtonDefinition button) { + ButtonDefinitionDTO buttonDTO = new ButtonDefinitionDTO(); + buttonDTO.row = button.getRow(); + buttonDTO.column = button.getColumn(); + buttonDTO.command = button.getCmd(); + buttonDTO.label = button.getLabel(); + buttonDTO.icon = button.getIcon(); + return buttonDTO; + } + + public static Sitemap map(SitemapDefinitionDTO sitemapDTO, SitemapFactory sitemapFactory) { + if (sitemapDTO.name == null) { + throw new IllegalArgumentException("Sitemap name must not be null"); + } + Sitemap sitemap = sitemapFactory.createSitemap(sitemapDTO.name); + sitemap.setLabel(sitemapDTO.label); + sitemap.setIcon(sitemapDTO.icon); + List widgets = sitemapDTO.widgets != null ? sitemapDTO.widgets : List.of(); + sitemap.setWidgets( + widgets.stream().map(widget -> map(widget, sitemap, sitemapFactory)).filter(Objects::nonNull).toList()); + return sitemap; + } + + private @Nullable static Widget map(WidgetDefinitionDTO widgetDTO, Parent parent, SitemapFactory sitemapFactory) { + if (widgetDTO.type == null) { + throw new IllegalArgumentException("All widgets must have a type"); + } + Widget widget = sitemapFactory.createWidget(widgetDTO.type, parent); + if (widget == null) { + return null; + } + switch (widget) { + case Switch switchWidget -> { + List mappings = widgetDTO.mappings != null ? widgetDTO.mappings : List.of(); + switchWidget.setMappings(mappings.stream().map(mapping -> map(mapping, sitemapFactory)).toList()); + } + case Buttongrid buttongridWidget -> { + List buttons = widgetDTO.buttons != null ? widgetDTO.buttons : List.of(); + buttongridWidget.setButtons(buttons.stream().map(button -> map(button, sitemapFactory)).toList()); + } + case Button buttonWidget -> { + if (widgetDTO.row != null) { + buttonWidget.setRow(widgetDTO.row); + } + if (widgetDTO.column != null) { + buttonWidget.setColumn(widgetDTO.column); + } + buttonWidget.setStateless(widgetDTO.stateless); + if (widgetDTO.command != null) { + buttonWidget.setCmd(widgetDTO.command); + } + buttonWidget.setReleaseCmd(widgetDTO.releaseCommand); + } + case Selection selectionWidget -> { + List mappings = widgetDTO.mappings != null ? widgetDTO.mappings : List.of(); + selectionWidget.setMappings(mappings.stream().map(mapping -> map(mapping, sitemapFactory)).toList()); + } + case Setpoint setpointWidget -> { + setpointWidget.setMinValue(widgetDTO.minValue); + setpointWidget.setMaxValue(widgetDTO.maxValue); + setpointWidget.setStep(widgetDTO.step); + } + case Slider sliderWidget -> { + sliderWidget.setMinValue(widgetDTO.minValue); + sliderWidget.setMaxValue(widgetDTO.maxValue); + sliderWidget.setStep(widgetDTO.step); + sliderWidget.setSwitchEnabled(widgetDTO.switchSupport); + sliderWidget.setReleaseOnly(widgetDTO.releaseOnly); + } + case Colortemperaturepicker colortemperaturepickerWidget -> { + colortemperaturepickerWidget.setMinValue(widgetDTO.minValue); + colortemperaturepickerWidget.setMaxValue(widgetDTO.maxValue); + } + case Input inputWidget -> { + inputWidget.setInputHint(widgetDTO.inputHint); + } + case Webview webviewWidget -> { + if (widgetDTO.url != null) { + webviewWidget.setUrl(widgetDTO.url); + } + webviewWidget.setHeight(widgetDTO.height); + } + case Mapview mapviewWidget -> { + mapviewWidget.setHeight(widgetDTO.height); + } + case Image imageWidget -> { + imageWidget.setUrl(widgetDTO.url); + imageWidget.setRefresh(widgetDTO.refresh); + } + case Video videoWidget -> { + if (widgetDTO.url != null) { + videoWidget.setUrl(widgetDTO.url); + } + videoWidget.setEncoding(widgetDTO.encoding); + } + case Chart chartWidget -> { + chartWidget.setRefresh(widgetDTO.refresh); + if (widgetDTO.period != null) { + chartWidget.setPeriod(widgetDTO.period); + } + if (widgetDTO.service != null) { + chartWidget.setService(widgetDTO.service); + } + chartWidget.setLegend(widgetDTO.legend); + chartWidget.setForceAsItem(widgetDTO.forceAsItem); + chartWidget.setYAxisDecimalPattern(widgetDTO.yAxisDecimalPattern); + chartWidget.setInterpolation(widgetDTO.interpolation); + } + case Default defaultWidget -> { + defaultWidget.setHeight(widgetDTO.height); + } + default -> { + // nothing to do + } + } + ; + + widget.setItem(widgetDTO.item); + widget.setLabel(widgetDTO.label); + widget.setIcon(widgetDTO.icon); + widget.setStaticIcon(widgetDTO.staticIcon); + + List iconRules = widgetDTO.iconRules != null ? widgetDTO.iconRules : List.of(); + widget.setIconRules(iconRules.stream().map(rule -> map(rule, sitemapFactory)).toList()); + List visibilityRules = widgetDTO.visibilityRules != null ? widgetDTO.visibilityRules : List.of(); + widget.setVisibility(visibilityRules.stream().map(rule -> map(rule, sitemapFactory)).toList()); + List labelColorRules = widgetDTO.labelColorRules != null ? widgetDTO.labelColorRules : List.of(); + widget.setLabelColor(labelColorRules.stream().map(rule -> map(rule, sitemapFactory)).toList()); + List valueColorRules = widgetDTO.valueColorRules != null ? widgetDTO.valueColorRules : List.of(); + widget.setValueColor(valueColorRules.stream().map(rule -> map(rule, sitemapFactory)).toList()); + List iconColorRules = widgetDTO.iconColorRules != null ? widgetDTO.iconColorRules : List.of(); + widget.setIconColor(iconColorRules.stream().map(rule -> map(rule, sitemapFactory)).toList()); + + if (widget instanceof LinkableWidget linkableWidget) { + List widgets = widgetDTO.widgets != null ? widgetDTO.widgets : List.of(); + linkableWidget + .setWidgets(widgets.stream().map(childWidget -> map(childWidget, linkableWidget, sitemapFactory)) + .filter(Objects::nonNull).toList()); + } + return widget; + } + + private static Mapping map(MappingDTO mappingDTO, SitemapFactory sitemapFactory) { + Mapping mapping = sitemapFactory.createMapping(); + if (mappingDTO.label != null) { + mapping.setLabel(mappingDTO.label); + } + mapping.setIcon(mappingDTO.icon); + if (mappingDTO.command != null) { + mapping.setCmd(mappingDTO.command); + } + mapping.setReleaseCmd(mappingDTO.releaseCommand); + return mapping; + } + + private static ButtonDefinition map(ButtonDefinitionDTO buttonDefinitionDTO, SitemapFactory sitemapFactory) { + ButtonDefinition buttonDefinition = sitemapFactory.createButtonDefinition(); + if (buttonDefinitionDTO.row != null) { + buttonDefinition.setRow(buttonDefinitionDTO.row); + } + if (buttonDefinitionDTO.column != null) { + buttonDefinition.setColumn(buttonDefinitionDTO.column); + } + if (buttonDefinitionDTO.command != null) { + buttonDefinition.setCmd(buttonDefinitionDTO.command); + } + if (buttonDefinitionDTO.label != null) { + buttonDefinition.setLabel(buttonDefinitionDTO.label); + } + buttonDefinition.setIcon(buttonDefinitionDTO.icon); + return buttonDefinition; + } + + private static Rule map(RuleDTO ruleDTO, SitemapFactory sitemapFactory) { + Rule rule = sitemapFactory.createRule(); + rule.setArgument(ruleDTO.argument); + List conditions = ruleDTO.conditions != null ? ruleDTO.conditions : List.of(); + rule.setConditions(conditions.stream().map(condition -> map(condition, sitemapFactory)).toList()); + return rule; + } + + private static Condition map(ConditionDTO conditionDTO, SitemapFactory sitemapFactory) { + Condition condition = sitemapFactory.createCondition(); + condition.setItem(conditionDTO.item); + condition.setCondition(conditionDTO.condition); + if (conditionDTO.value != null) { + condition.setValue(conditionDTO.value); + } + return condition; + } +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/SitemapDefinitionDTO.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/SitemapDefinitionDTO.java new file mode 100644 index 00000000000..3c02911fd6d --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/SitemapDefinitionDTO.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * This is a data transfer object that is used to serialize sitemaps. + * + * @author Mark Herwege - Initial contribution + */ +@Schema(name = "SitemapDefinition") +public class SitemapDefinitionDTO extends AbstractSitemapDTO { + + public List widgets; +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/WidgetDefinitionDTO.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/WidgetDefinitionDTO.java new file mode 100644 index 00000000000..7745085a13f --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/dto/WidgetDefinitionDTO.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * This is a data transfer object that is used to serialize widgets. + * + * @author Mark Herwege - Initial contribution + */ +@Schema(name = "WidgetDefinition") +public class WidgetDefinitionDTO extends AbstractWidgetDTO { + + public String item; + + public List visibilityRules; + public List iconRules; + public List labelColorRules; + public List valueColorRules; + public List iconColorRules; + + public List buttons; + + public List widgets; +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/fileconverter/SitemapParser.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/fileconverter/SitemapParser.java new file mode 100644 index 00000000000..186ced0e5fd --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/fileconverter/SitemapParser.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.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.converter.ObjectParser; +import org.openhab.core.sitemap.Sitemap; + +/** + * {@link SitemapParser} is the interface to implement by any {@link Sitemap} parser. + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +public interface SitemapParser extends ObjectParser { + + /** + * Parse the provided {@code syntax} string without impacting the sitemap registry. + * + * @param syntax the syntax in format. + * @param errors the {@link List} to use to report errors. + * @param warnings the {@link List} to be used to report warnings. + * @return The model name used for parsing if the parsing succeeded without errors; {@code null} otherwise. + */ + @Override + @Nullable + String startParsingFormat(String syntax, List errors, List warnings); + + /** + * Get the {@link Sitemap} objects found when parsing the format. + * + * @param modelName the model name used when parsing. + * @return The {@link Collection} of {@link Sitemap}s. + */ + @Override + Collection getParsedObjects(String modelName); +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/fileconverter/SitemapSerializer.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/fileconverter/SitemapSerializer.java new file mode 100644 index 00000000000..f4cc9dba2a7 --- /dev/null +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/fileconverter/SitemapSerializer.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2026 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.sitemap.fileconverter; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.converter.ObjectSerializer; +import org.openhab.core.sitemap.Sitemap; + +/** + * {@link SitemapSerializer} is the interface to implement by any file generator for {@link Sitemap} object. + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +public interface SitemapSerializer extends ObjectSerializer { + + /** + * Specify the {@link List} of {@link Sitemap}s to serialize and associate them with an identifier. + * + * @param id the identifier of the {@link Sitemap} format generation. + * @param sitemaps the {@link List} of {@link Sitemap}s to serialize. + */ + void setSitemapsToBeSerialized(String id, List sitemaps); +} diff --git a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/internal/SitemapImpl.java b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/internal/SitemapImpl.java index f3f8ab87b08..ef09f81ed86 100644 --- a/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/internal/SitemapImpl.java +++ b/bundles/org.openhab.core.sitemap/src/main/java/org/openhab/core/sitemap/internal/SitemapImpl.java @@ -31,10 +31,22 @@ public class SitemapImpl implements Sitemap { private @Nullable String icon; private List widgets = new CopyOnWriteArrayList<>(); + private static final String NAME_PATTERN = "^[a-zA-Z0-9_]+$"; + public SitemapImpl() { } + /** + * Method to create a sitemap with the given name. + * + * @param name the name of the sitemap, must match the pattern defined in NAME_PATTERN. + * + * @throws IllegalArgumentException if the provided name is null or does not match the required pattern. + */ public SitemapImpl(String name) { + if (!name.matches(NAME_PATTERN)) { + throw new IllegalArgumentException("Sitemap name must match the pattern: " + NAME_PATTERN); + } this.name = name; } diff --git a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/components/ManagedSitemapProvider.java b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/components/ManagedSitemapProvider.java new file mode 100644 index 00000000000..e7e201222b6 --- /dev/null +++ b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/components/ManagedSitemapProvider.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 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.ui.components; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.common.registry.ManagedProvider; +import org.openhab.core.sitemap.Sitemap; + +/** + * Marker interface for a {@link ManagedProvider} of {@link Sitemap}s. + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +public interface ManagedSitemapProvider extends ManagedProvider { + +} diff --git a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentSitemapMapper.java b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentSitemapMapper.java new file mode 100644 index 00000000000..90f1d3503aa --- /dev/null +++ b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentSitemapMapper.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2010-2026 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.ui.internal.components; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.sitemap.Button; +import org.openhab.core.sitemap.ButtonDefinition; +import org.openhab.core.sitemap.Buttongrid; +import org.openhab.core.sitemap.Chart; +import org.openhab.core.sitemap.Colortemperaturepicker; +import org.openhab.core.sitemap.Condition; +import org.openhab.core.sitemap.Default; +import org.openhab.core.sitemap.Image; +import org.openhab.core.sitemap.Input; +import org.openhab.core.sitemap.LinkableWidget; +import org.openhab.core.sitemap.Mapping; +import org.openhab.core.sitemap.Mapview; +import org.openhab.core.sitemap.Rule; +import org.openhab.core.sitemap.Selection; +import org.openhab.core.sitemap.Setpoint; +import org.openhab.core.sitemap.Sitemap; +import org.openhab.core.sitemap.Slider; +import org.openhab.core.sitemap.Switch; +import org.openhab.core.sitemap.Video; +import org.openhab.core.sitemap.Webview; +import org.openhab.core.sitemap.Widget; +import org.openhab.core.ui.components.RootUIComponent; +import org.openhab.core.ui.components.UIComponent; + +/** + * The {@link UIComponentSitemapMapper} is a utility class to map sitemaps into UI Component objects. + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +public class UIComponentSitemapMapper { + + public static RootUIComponent map(Sitemap element) { + String sitemapName = element.getName(); + sitemapName = sitemapName.startsWith(UIComponentSitemapProvider.SITEMAP_PREFIX) + ? sitemapName.substring(UIComponentSitemapProvider.SITEMAP_PREFIX.length()) + : sitemapName; + RootUIComponent sitemapComponent = new RootUIComponent(sitemapName, "Sitemap"); + addConfig(sitemapComponent, "label", element.getLabel()); + addConfig(sitemapComponent, "icon", element.getIcon()); + + if (!element.getWidgets().isEmpty()) { + sitemapComponent.addSlot("widgets"); + element.getWidgets().stream().map(UIComponentSitemapMapper::map) + .forEach(component -> sitemapComponent.addComponent("widgets", component)); + } + + return sitemapComponent; + } + + public static UIComponent map(Widget widget) { + UIComponent widgetComponent = new UIComponent(widget.getWidgetType()); + addConfig(widgetComponent, "item", widget.getItem()); + addConfig(widgetComponent, "label", widget.getLabel()); + addConfig(widgetComponent, "icon", widget.getIcon()); + if (widget.isStaticIcon()) { + addConfig(widgetComponent, "staticIcon", true); + } + + addConfig(widgetComponent, "iconRules", map(widget.getIconRules())); + addConfig(widgetComponent, "visibility", map(widget.getVisibility())); + addConfig(widgetComponent, "labelColor", map(widget.getLabelColor())); + addConfig(widgetComponent, "valueColor", map(widget.getValueColor())); + addConfig(widgetComponent, "iconColor", map(widget.getIconColor())); + + switch (widget) { + case Switch switchWidget -> { + addConfig(widgetComponent, "mappings", map(switchWidget.getMappings())); + } + case Buttongrid buttongridWidget -> { + addConfig(widgetComponent, "buttons", map(buttongridWidget.getButtons())); + } + case Button buttonWidget -> { + addConfig(widgetComponent, "row", buttonWidget.getRow()); + addConfig(widgetComponent, "column", buttonWidget.getColumn()); + addConfig(widgetComponent, "cmd", buttonWidget.getCmd()); + addConfig(widgetComponent, "releaseCmd", buttonWidget.getReleaseCmd()); + if (buttonWidget.isStateless()) { + addConfig(widgetComponent, "stateless", true); + } + } + case Selection selectionWidget -> { + addConfig(widgetComponent, "mappings", map(selectionWidget.getMappings())); + } + case Setpoint setpointWidget -> { + addConfig(widgetComponent, "minValue", setpointWidget.getMinValue()); + addConfig(widgetComponent, "maxValue", setpointWidget.getMaxValue()); + addConfig(widgetComponent, "step", setpointWidget.getStep()); + } + case Slider sliderWidget -> { + addConfig(widgetComponent, "minValue", sliderWidget.getMinValue()); + addConfig(widgetComponent, "maxValue", sliderWidget.getMaxValue()); + addConfig(widgetComponent, "step", sliderWidget.getStep()); + if (sliderWidget.isSwitchEnabled()) { + addConfig(widgetComponent, "switchEnabled", true); + } + if (sliderWidget.isReleaseOnly()) { + addConfig(widgetComponent, "releaseOnly", true); + } + } + case Colortemperaturepicker colorTemperaturePickerWidget -> { + addConfig(widgetComponent, "minValue", colorTemperaturePickerWidget.getMinValue()); + addConfig(widgetComponent, "maxValue", colorTemperaturePickerWidget.getMaxValue()); + } + case Input inputWidget -> { + addConfig(widgetComponent, "inputHint", inputWidget.getInputHint()); + } + case Webview webviewWidget -> { + addConfig(widgetComponent, "url", webviewWidget.getUrl()); + int height = webviewWidget.getHeight(); + if (height > 0) { + addConfig(widgetComponent, "height", height); + } + } + case Mapview mapviewWidget -> { + int height = mapviewWidget.getHeight(); + if (height > 0) { + addConfig(widgetComponent, "height", height); + } + } + case Image imageWidget -> { + addConfig(widgetComponent, "url", imageWidget.getUrl()); + int refresh = imageWidget.getRefresh(); + if (refresh > 0) { + addConfig(widgetComponent, "refresh", refresh); + } + } + case Video videoWidget -> { + addConfig(widgetComponent, "url", videoWidget.getUrl()); + addConfig(widgetComponent, "encoding", videoWidget.getEncoding()); + } + case Chart chartWidget -> { + int refresh = chartWidget.getRefresh(); + if (refresh > 0) { + addConfig(widgetComponent, "refresh", refresh); + } + addConfig(widgetComponent, "period", chartWidget.getPeriod()); + addConfig(widgetComponent, "service", chartWidget.getService()); + addConfig(widgetComponent, "legend", chartWidget.hasLegend()); + addConfig(widgetComponent, "forceAsItem", chartWidget.forceAsItem()); + addConfig(widgetComponent, "yAxisDecimalPattern", chartWidget.getYAxisDecimalPattern()); + addConfig(widgetComponent, "interpolation", chartWidget.getInterpolation()); + } + case Default defaultWidget -> { + int height = defaultWidget.getHeight(); + if (height > 0) { + addConfig(widgetComponent, "height", height); + } + } + default -> { + // Nothing to do + } + } + + if (widget instanceof LinkableWidget linkableWidget && !linkableWidget.getWidgets().isEmpty()) { + widgetComponent.addSlot("widgets"); + linkableWidget.getWidgets().stream().map(UIComponentSitemapMapper::map) + .forEach(component -> widgetComponent.addComponent("widgets", component)); + } + return widgetComponent; + } + + private static @Nullable Collection map(List objects) { + if (objects.isEmpty()) { + return null; + } + return objects.stream().map(UIComponentSitemapMapper::map).filter(Objects::nonNull).toList(); + } + + private static @Nullable String map(Object object) { + if (object instanceof Rule rule) { + return map(rule); + } else if (object instanceof Mapping mapping) { + return map(mapping); + } else if (object instanceof ButtonDefinition buttonDefinition) { + return map(buttonDefinition); + } + return null; + } + + private static String map(Rule rule) { + String ruleString = rule.getConditions().stream().map(UIComponentSitemapMapper::map) + .collect(Collectors.joining(" AND ")); + String argument = rule.getArgument(); + if (argument != null) { + ruleString = ruleString + "=\"" + argument + "\""; + } + return ruleString; + } + + private static String map(Condition condition) { + StringBuilder builder = new StringBuilder(); + String item = condition.getItem(); + if (item != null) { + builder.append(item); + } + String operator = condition.getCondition(); + if (operator != null) { + builder.append(operator); + } + builder.append("\"").append(condition.getValue()).append("\""); + return builder.toString(); + } + + private static String map(Mapping mapping) { + StringBuilder builder = new StringBuilder(); + builder.append(mapping.getCmd()); + String releaseCmd = mapping.getReleaseCmd(); + if (releaseCmd != null) { + builder.append(":").append(releaseCmd); + } + builder.append("=\"").append(mapping.getLabel()).append("\""); + String icon = mapping.getIcon(); + if (icon != null) { + builder.append("=").append(icon); + } + return builder.toString(); + } + + private static String map(ButtonDefinition button) { + StringBuilder builder = new StringBuilder(); + builder.append(button.getRow()).append(":").append(button.getColumn()).append(":").append(button.getCmd()); + builder.append("=").append(button.getLabel()); + String icon = button.getIcon(); + if (icon != null) { + builder.append("=").append(icon); + } + return builder.toString(); + } + + private static void addConfig(UIComponent component, String key, @Nullable Object value) { + if (value != null) { + component.addConfig(key, value); + } + } +} diff --git a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentSitemapProvider.java b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentSitemapProvider.java index 839404593fa..c9cbabf8312 100644 --- a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentSitemapProvider.java +++ b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentSitemapProvider.java @@ -54,6 +54,7 @@ import org.openhab.core.sitemap.registry.SitemapFactory; import org.openhab.core.sitemap.registry.SitemapProvider; import org.openhab.core.sitemap.registry.SitemapRegistry; +import org.openhab.core.ui.components.ManagedSitemapProvider; import org.openhab.core.ui.components.RootUIComponent; import org.openhab.core.ui.components.UIComponent; import org.openhab.core.ui.components.UIComponentRegistry; @@ -79,16 +80,19 @@ * @author Mark Herwege - Add support for Button element * @author Laurent Garnier - Added support for new sitemap element Colortemperaturepicker * @author Mark Herwege - Implement sitemap registry + * @author Mark Herwege - Make provider managed and add support for adding/updating/removing sitemaps via the provider + * interface */ @NonNullByDefault -@Component(service = SitemapProvider.class, immediate = true) +@Component(service = { SitemapProvider.class, ManagedSitemapProvider.class }, immediate = true) public class UIComponentSitemapProvider extends AbstractProvider - implements SitemapProvider, RegistryChangeListener { + implements ManagedSitemapProvider, SitemapProvider, RegistryChangeListener { + private final Logger logger = LoggerFactory.getLogger(UIComponentSitemapProvider.class); public static final String SITEMAP_NAMESPACE = "system:sitemap"; - private static final String SITEMAP_PREFIX = "uicomponents_"; + static final String SITEMAP_PREFIX = "uicomponents_"; private static final Pattern CONDITION_PATTERN = Pattern .compile("((?[A-Za-z]\\w*)?\\s*(?==|!=|<=|>=|<|>))?\\s*(?(\\+|-)?.+)"); @@ -476,4 +480,36 @@ public void updated(RootUIComponent oldElement, RootUIComponent element) { } } } + + @Override + public void add(Sitemap element) { + UIComponentRegistry sitemapComponentRegistry = this.sitemapComponentRegistry; + if (sitemapComponentRegistry != null) { + sitemapComponentRegistry.add(UIComponentSitemapMapper.map(element)); + } + } + + @Override + public @Nullable Sitemap remove(String key) { + Sitemap sitemap = sitemaps.get(key); + UIComponentRegistry sitemapComponentRegistry = this.sitemapComponentRegistry; + if (sitemapComponentRegistry != null) { + String sitemapName = key.startsWith(SITEMAP_PREFIX) ? key.substring(SITEMAP_PREFIX.length()) : key; + sitemapComponentRegistry.remove(sitemapName); + return sitemap; + } + return null; + } + + @Override + public @Nullable Sitemap update(Sitemap element) { + Sitemap sitemap = remove(element.getName()); + add(element); + return sitemap; + } + + @Override + public @Nullable Sitemap get(String key) { + return getSitemap(key); + } }