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 extends IFormatter> 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);
+ }
}