Add file-based YAML support for UI pages and widgets (#5493)

Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
This commit is contained in:
jimtng
2026-05-01 18:27:42 +02:00
committed by GitHub
parent 392241a029
commit be7f1ebd84
12 changed files with 742 additions and 13 deletions
@@ -0,0 +1,46 @@
/*
* 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.ui;
import java.util.Date;
import org.openhab.core.ui.components.RootUIComponent;
/**
* A {@link RootUIComponent} enriched with runtime-computed information for REST API responses.
*
* @author Jimmy Tanagra - Initial contribution
*/
public class EnrichedRootUIComponent extends RootUIComponent {
/**
* Whether the component can be modified or deleted through the REST API.
* Components loaded from YAML configuration files are not editable.
*/
public boolean editable;
public EnrichedRootUIComponent(RootUIComponent component, boolean editable) {
super(component.getUID(), component.getType());
setConfig(component.getConfig());
setSlots(component.getSlots());
if (component.getTags() != null) {
addTags(component.getTags());
}
Date timestamp = component.getTimestamp();
if (timestamp != null) {
setTimestamp(timestamp);
}
setProps(component.getProps());
this.editable = editable;
}
}
@@ -44,6 +44,7 @@ import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.io.rest.RESTConstants; import org.openhab.core.io.rest.RESTConstants;
import org.openhab.core.io.rest.RESTResource; import org.openhab.core.io.rest.RESTResource;
import org.openhab.core.io.rest.Stream2JSONInputStream; import org.openhab.core.io.rest.Stream2JSONInputStream;
import org.openhab.core.io.rest.ui.EnrichedRootUIComponent;
import org.openhab.core.io.rest.ui.TileDTO; import org.openhab.core.io.rest.ui.TileDTO;
import org.openhab.core.ui.components.RootUIComponent; import org.openhab.core.ui.components.RootUIComponent;
import org.openhab.core.ui.components.UIComponentRegistry; import org.openhab.core.ui.components.UIComponentRegistry;
@@ -127,18 +128,20 @@ public class UIResource implements RESTResource {
@Path("/components/{namespace}") @Path("/components/{namespace}")
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Operation(operationId = "getRegisteredUIComponentsInNamespace", summary = "Get all registered UI components in the specified namespace.", responses = { @Operation(operationId = "getRegisteredUIComponentsInNamespace", summary = "Get all registered UI components in the specified namespace.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = RootUIComponent.class)))) }) @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = EnrichedRootUIComponent.class)))) })
public Response getAllComponents(@Context Request request, @PathParam("namespace") String namespace, public Response getAllComponents(@Context Request request, @PathParam("namespace") String namespace,
@QueryParam("summary") @Parameter(description = "summary fields only") @Nullable Boolean summary) { @QueryParam("summary") @Parameter(description = "summary fields only") @Nullable Boolean summary) {
UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace); UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace);
Stream<RootUIComponent> components = registry.getAll().stream(); Stream<EnrichedRootUIComponent> components = registry.getAll().stream()
.map(c -> new EnrichedRootUIComponent(c, registry.isEditable(c.getUID())));
if (summary != null && summary) { if (summary != null && summary) {
components = components.map(c -> { components = components.map(c -> {
RootUIComponent component = new RootUIComponent(c.getUID(), c.getType()); EnrichedRootUIComponent component = new EnrichedRootUIComponent(
new RootUIComponent(c.getUID(), c.getType()), c.editable);
@Nullable @Nullable
Set<String> tags = c.getTags(); Set<String> tags = c.getTags();
if (tags != null) { if (tags != null) {
component.addTags(c.getTags()); component.addTags(tags);
} }
@Nullable @Nullable
Date timestamp = c.getTimestamp(); Date timestamp = c.getTimestamp();
@@ -178,7 +181,7 @@ public class UIResource implements RESTResource {
@Path("/components/{namespace}/{componentUID}") @Path("/components/{namespace}/{componentUID}")
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Operation(operationId = "getUIComponentInNamespace", summary = "Get a specific UI component in the specified namespace.", responses = { @Operation(operationId = "getUIComponentInNamespace", summary = "Get a specific UI component in the specified namespace.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = RootUIComponent.class))), @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = EnrichedRootUIComponent.class))),
@ApiResponse(responseCode = "404", description = "Component not found") }) @ApiResponse(responseCode = "404", description = "Component not found") })
public Response getComponentByUID(@PathParam("namespace") String namespace, public Response getComponentByUID(@PathParam("namespace") String namespace,
@PathParam("componentUID") String componentUID) { @PathParam("componentUID") String componentUID) {
@@ -187,7 +190,7 @@ public class UIResource implements RESTResource {
if (component == null) { if (component == null) {
return Response.status(Status.NOT_FOUND).build(); return Response.status(Status.NOT_FOUND).build();
} }
return Response.ok(component).build(); return Response.ok(new EnrichedRootUIComponent(component, registry.isEditable(componentUID))).build();
} }
@POST @POST
@@ -197,12 +200,12 @@ public class UIResource implements RESTResource {
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Operation(operationId = "addUIComponentToNamespace", summary = "Add a UI component in the specified namespace.", security = { @Operation(operationId = "addUIComponentToNamespace", summary = "Add a UI component in the specified namespace.", security = {
@SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = RootUIComponent.class))) }) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = EnrichedRootUIComponent.class))) })
public Response addComponent(@PathParam("namespace") String namespace, RootUIComponent component) { public Response addComponent(@PathParam("namespace") String namespace, RootUIComponent component) {
UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace); UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace);
component.updateTimestamp(); component.updateTimestamp();
RootUIComponent createdComponent = registry.add(component); RootUIComponent createdComponent = registry.add(component);
return Response.ok(createdComponent).build(); return Response.ok(new EnrichedRootUIComponent(createdComponent, true)).build();
} }
@PUT @PUT
@@ -212,8 +215,9 @@ public class UIResource implements RESTResource {
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Operation(operationId = "updateUIComponentInNamespace", summary = "Update a specific UI component in the specified namespace.", security = { @Operation(operationId = "updateUIComponentInNamespace", summary = "Update a specific UI component in the specified namespace.", security = {
@SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = RootUIComponent.class))), @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = EnrichedRootUIComponent.class))),
@ApiResponse(responseCode = "404", description = "Component not found") }) @ApiResponse(responseCode = "404", description = "Component not found"),
@ApiResponse(responseCode = "405", description = "Component is not editable.") })
public Response updateComponent(@PathParam("namespace") String namespace, public Response updateComponent(@PathParam("namespace") String namespace,
@PathParam("componentUID") String componentUID, RootUIComponent component) { @PathParam("componentUID") String componentUID, RootUIComponent component) {
UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace); UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace);
@@ -221,13 +225,17 @@ public class UIResource implements RESTResource {
if (existingComponent == null) { if (existingComponent == null) {
return Response.status(Status.NOT_FOUND).build(); return Response.status(Status.NOT_FOUND).build();
} }
if (!registry.isEditable(componentUID)) {
return Response.status(Status.METHOD_NOT_ALLOWED)
.entity("Cannot update component " + componentUID + " as it is not editable.").build();
}
if (!componentUID.equals(component.getUID())) { if (!componentUID.equals(component.getUID())) {
throw new InvalidParameterException( throw new InvalidParameterException(
"The component UID in the body of the request should match the UID in the URL"); "The component UID in the body of the request should match the UID in the URL");
} }
component.updateTimestamp(); component.updateTimestamp();
registry.update(component); registry.update(component);
return Response.ok(component).build(); return Response.ok(new EnrichedRootUIComponent(component, true)).build();
} }
@DELETE @DELETE
@@ -237,7 +245,8 @@ public class UIResource implements RESTResource {
@Operation(operationId = "removeUIComponentFromNamespace", summary = "Remove a specific UI component in the specified namespace.", security = { @Operation(operationId = "removeUIComponentFromNamespace", summary = "Remove a specific UI component in the specified namespace.", security = {
@SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = {
@ApiResponse(responseCode = "200", description = "OK"), @ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "404", description = "Component not found") }) @ApiResponse(responseCode = "404", description = "Component not found"),
@ApiResponse(responseCode = "405", description = "Component is not editable.") })
public Response deleteComponent(@PathParam("namespace") String namespace, public Response deleteComponent(@PathParam("namespace") String namespace,
@PathParam("componentUID") String componentUID) { @PathParam("componentUID") String componentUID) {
UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace); UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace);
@@ -245,6 +254,10 @@ public class UIResource implements RESTResource {
if (component == null) { if (component == null) {
return Response.status(Status.NOT_FOUND).build(); return Response.status(Status.NOT_FOUND).build();
} }
if (!registry.isEditable(componentUID)) {
return Response.status(Status.METHOD_NOT_ALLOWED)
.entity("Cannot delete component " + componentUID + " as it is not editable.").build();
}
registry.remove(componentUID); registry.remove(componentUID);
return Response.ok().build(); return Response.ok().build();
} }
@@ -45,5 +45,10 @@
<artifactId>org.openhab.core.automation.module.script.rulesupport</artifactId> <artifactId>org.openhab.core.automation.module.script.rulesupport</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.ui</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -45,10 +45,12 @@ import org.openhab.core.model.yaml.YamlElementName;
import org.openhab.core.model.yaml.YamlModelListener; import org.openhab.core.model.yaml.YamlModelListener;
import org.openhab.core.model.yaml.YamlModelRepository; import org.openhab.core.model.yaml.YamlModelRepository;
import org.openhab.core.model.yaml.internal.items.YamlItemDTO; import org.openhab.core.model.yaml.internal.items.YamlItemDTO;
import org.openhab.core.model.yaml.internal.pages.YamlPageDTO;
import org.openhab.core.model.yaml.internal.rules.YamlRuleDTO; 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.rules.YamlRuleTemplateDTO;
import org.openhab.core.model.yaml.internal.semantics.YamlSemanticTagDTO; import org.openhab.core.model.yaml.internal.semantics.YamlSemanticTagDTO;
import org.openhab.core.model.yaml.internal.things.YamlThingDTO; import org.openhab.core.model.yaml.internal.things.YamlThingDTO;
import org.openhab.core.model.yaml.internal.widgets.YamlWidgetDTO;
import org.openhab.core.service.WatchService; import org.openhab.core.service.WatchService;
import org.openhab.core.service.WatchService.Kind; import org.openhab.core.service.WatchService.Kind;
import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Activate;
@@ -85,6 +87,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLParser;
* @author Laurent Garnier - new parameters to retrieve errors and warnings when loading a file * @author Laurent Garnier - new parameters to retrieve errors and warnings when loading a file
* @author Laurent Garnier - Added methods addElementsToBeGenerated, generateFileFormat, createIsolatedModel and * @author Laurent Garnier - Added methods addElementsToBeGenerated, generateFileFormat, createIsolatedModel and
* removeIsolatedModel * removeIsolatedModel
* @author Jimmy Tanagra - Add YamlPageDTO and YamlWidgetDTO
*/ */
@NonNullByDefault @NonNullByDefault
@Component(immediate = true) @Component(immediate = true)
@@ -97,7 +100,9 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
getElementName(YamlRuleTemplateDTO.class), // "ruleTemplates" getElementName(YamlRuleTemplateDTO.class), // "ruleTemplates"
getElementName(YamlSemanticTagDTO.class), // "tags" getElementName(YamlSemanticTagDTO.class), // "tags"
getElementName(YamlThingDTO.class), // "things" getElementName(YamlThingDTO.class), // "things"
getElementName(YamlItemDTO.class) // "items" getElementName(YamlItemDTO.class), // "items"
getElementName(YamlPageDTO.class), // "pages"
getElementName(YamlWidgetDTO.class) // "widgets"
); );
private static final String UNWANTED_EXCEPTION_TEXT = "at [Source: UNKNOWN; byte offset: #UNKNOWN] "; private static final String UNWANTED_EXCEPTION_TEXT = "at [Source: UNKNOWN; byte offset: #UNKNOWN] ";
@@ -0,0 +1,111 @@
/*
* 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.pages;
import java.util.Date;
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.config.core.dto.ConfigDescriptionDTO;
import org.openhab.core.model.yaml.YamlElement;
import org.openhab.core.model.yaml.YamlElementName;
import org.openhab.core.model.yaml.internal.util.FlexibleDateDeserializer;
import org.openhab.core.ui.components.UIComponent;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
/**
* The {@link YamlPageDTO} is a data transfer object used to serialize a UI page in a YAML configuration file.
* It maps to a {@link org.openhab.core.ui.components.RootUIComponent} in the {@code ui:page} namespace.
*
* @author Jimmy Tanagra - Initial contribution
*/
@YamlElementName("pages")
public class YamlPageDTO implements YamlElement, Cloneable {
public String uid;
public String component;
public Map<String, Object> config;
public Map<String, List<UIComponent>> slots;
public Set<String> tags;
public ConfigDescriptionDTO props;
@JsonDeserialize(using = FlexibleDateDeserializer.class)
public @Nullable Date timestamp;
public YamlPageDTO() {
}
@Override
public @NonNull String getId() {
return uid == null ? "" : uid;
}
@Override
public void setId(@NonNull String id) {
uid = id;
}
@Override
public YamlElement cloneWithoutId() {
YamlPageDTO copy;
try {
copy = (YamlPageDTO) super.clone();
copy.uid = null;
return copy;
} catch (CloneNotSupportedException e) {
// Will never happen
return new YamlPageDTO();
}
}
@Override
public boolean isValid(@Nullable List<@NonNull String> errors, @Nullable List<@NonNull String> warnings) {
if (uid == null || uid.isBlank()) {
addToList(errors, "invalid page: uid is missing while mandatory");
return false;
}
if (component == null || component.isBlank()) {
addToList(errors, "invalid page \"%s\": component is missing while mandatory".formatted(uid));
return false;
}
return true;
}
private void addToList(@Nullable List<@NonNull String> list, String value) {
if (list != null) {
list.add(value);
}
}
@Override
public int hashCode() {
return Objects.hash(uid, component, config, slots, tags, props);
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
} else if (obj == null || getClass() != obj.getClass()) {
return false;
}
YamlPageDTO other = (YamlPageDTO) obj;
return Objects.equals(uid, other.uid) && Objects.equals(component, other.component)
&& Objects.equals(config, other.config) && Objects.equals(slots, other.slots)
&& Objects.equals(tags, other.tags) && Objects.equals(props, other.props);
}
}
@@ -0,0 +1,165 @@
/*
* 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.pages;
import static org.openhab.core.model.yaml.YamlModelUtils.isIsolatedModel;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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.ui.components.RootUIComponent;
import org.openhab.core.ui.components.UIComponentProvider;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* {@link YamlPageProvider} is an OSGi service that allows defining UI pages in YAML configuration files.
* These pages are automatically exposed to the {@link org.openhab.core.ui.components.UIComponentRegistry}
* under the {@code ui:page} namespace.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
@Component(immediate = true, service = { UIComponentProvider.class, YamlPageProvider.class, YamlModelListener.class })
public class YamlPageProvider extends AbstractProvider<RootUIComponent>
implements UIComponentProvider, YamlModelListener<YamlPageDTO> {
public static final String PAGES_NAMESPACE = "ui:page";
private final Logger logger = LoggerFactory.getLogger(YamlPageProvider.class);
private final Map<String, Collection<RootUIComponent>> pagesMap = new ConcurrentHashMap<>();
@Deactivate
public void deactivate() {
pagesMap.clear();
}
@Override
public String getNamespace() {
return PAGES_NAMESPACE;
}
@Override
public Collection<RootUIComponent> getAll() {
// Ignore isolated models
return pagesMap.keySet().stream().filter(name -> !isIsolatedModel(name))
.map(name -> pagesMap.getOrDefault(name, List.of())).flatMap(Collection::stream).toList();
}
@Override
public Class<YamlPageDTO> getElementClass() {
return YamlPageDTO.class;
}
@Override
public boolean isVersionSupported(int version) {
return version >= 1;
}
@Override
public boolean isDeprecated() {
return false;
}
@Override
public void addedModel(String modelName, Collection<YamlPageDTO> elements) {
List<RootUIComponent> added = elements.stream().map(this::mapPage).filter(Objects::nonNull).toList();
Collection<RootUIComponent> modelPages = Objects
.requireNonNull(pagesMap.computeIfAbsent(modelName, k -> new ArrayList<>()));
modelPages.addAll(added);
added.forEach(page -> {
logger.debug("model {} added page {}", modelName, page.getUID());
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(page);
}
});
}
@Override
public void updatedModel(String modelName, Collection<YamlPageDTO> elements) {
List<RootUIComponent> updated = elements.stream().map(this::mapPage).filter(Objects::nonNull).toList();
Collection<RootUIComponent> modelPages = Objects
.requireNonNull(pagesMap.computeIfAbsent(modelName, k -> new ArrayList<>()));
updated.forEach(page -> {
String uid = page.getUID();
modelPages.stream().filter(p -> p.getUID().equals(uid)).findFirst().ifPresentOrElse(oldPage -> {
modelPages.remove(oldPage);
modelPages.add(page);
logger.debug("model {} updated page {}", modelName, uid);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutUpdatedElement(oldPage, page);
}
}, () -> {
modelPages.add(page);
logger.debug("model {} added page {}", modelName, uid);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(page);
}
});
});
}
@Override
public void removedModel(String modelName, Collection<YamlPageDTO> elements) {
Collection<RootUIComponent> modelPages = pagesMap.getOrDefault(modelName, List.of());
elements.stream().map(YamlPageDTO::getId).forEach(uid -> {
modelPages.stream().filter(p -> p.getUID().equals(uid)).findFirst().ifPresentOrElse(oldPage -> {
modelPages.remove(oldPage);
logger.debug("model {} removed page {}", modelName, uid);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutRemovedElement(oldPage);
}
}, () -> logger.debug("model {} page {} not found", modelName, uid));
});
if (modelPages.isEmpty()) {
pagesMap.remove(modelName);
}
}
private @Nullable RootUIComponent mapPage(YamlPageDTO dto) {
String uid = dto.uid;
if (uid == null || uid.isBlank()) {
logger.warn("Skipping page with missing uid");
return null;
}
RootUIComponent page = new RootUIComponent(uid, dto.component != null ? dto.component : "");
if (dto.config != null) {
page.setConfig(dto.config);
}
page.setSlots(dto.slots);
if (dto.tags != null) {
page.addTags(dto.tags);
}
if (dto.props != null) {
page.setProps(dto.props);
}
Date timestamp = dto.timestamp;
if (timestamp != null) {
page.setTimestamp(timestamp);
}
return page;
}
}
@@ -0,0 +1,89 @@
/*
* 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.util;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
/**
* A Jackson deserializer for {@link Date} that accepts a wide range of formats:
* <ul>
* <li>Epoch milliseconds (numeric)</li>
* <li>ISO 8601 (e.g. {@code 2021-03-08T19:41:03Z})</li>
* <li>Legacy openHAB UI format (e.g. {@code Mar 8, 2021, 7:41:03 PM})</li>
* </ul>
*
* @author Jimmy Tanagra - Initial contribution
*/
public class FlexibleDateDeserializer extends StdDeserializer<Date> {
private static final long serialVersionUID = 1L;
private static final List<String> STRING_PATTERNS = List.of( //
"MMM d, yyyy, h:mm:ss a", // legacy openHAB UI export format
"MMM d, yyyy, hh:mm:ss a", // zero-padded hour variant
"yyyy-MM-dd'T'HH:mm:ssXXX", // ISO 8601 with offset
"yyyy-MM-dd'T'HH:mm:ss", // ISO 8601 local
"yyyy-MM-dd HH:mm:ss", // common SQL-like format
"yyyy-MM-dd" // date only
);
public FlexibleDateDeserializer() {
super(Date.class);
}
@Override
public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {
return new Date(p.getLongValue());
}
String text = p.getText().trim();
// Try each string pattern
for (String pattern : STRING_PATTERNS) {
try {
SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.US);
sdf.setLenient(false);
return sdf.parse(text);
} catch (ParseException ignored) {
}
}
// Try ISO 8601 via Instant (handles Z, offsets, etc.)
try {
return Date.from(Instant.parse(text));
} catch (DateTimeParseException ignored) {
}
// Try parsing as epoch millis from string
try {
return new Date(Long.parseLong(text));
} catch (NumberFormatException ignored) {
}
throw ctxt.weirdStringException(text, Date.class,
"Cannot parse date: unrecognized format. Accepted formats: epoch (ms), ISO 8601, or 'MMM d, yyyy, h:mm:ss a'");
}
}
@@ -0,0 +1,111 @@
/*
* 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.widgets;
import java.util.Date;
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.config.core.dto.ConfigDescriptionDTO;
import org.openhab.core.model.yaml.YamlElement;
import org.openhab.core.model.yaml.YamlElementName;
import org.openhab.core.model.yaml.internal.util.FlexibleDateDeserializer;
import org.openhab.core.ui.components.UIComponent;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
/**
* The {@link YamlWidgetDTO} is a data transfer object used to serialize a UI widget in a YAML configuration file.
* It maps to a {@link org.openhab.core.ui.components.RootUIComponent} in the {@code ui:widget} namespace.
*
* @author Jimmy Tanagra - Initial contribution
*/
@YamlElementName("widgets")
public class YamlWidgetDTO implements YamlElement, Cloneable {
public String uid;
public String component;
public Map<String, Object> config;
public Map<String, List<UIComponent>> slots;
public Set<String> tags;
public ConfigDescriptionDTO props;
@JsonDeserialize(using = FlexibleDateDeserializer.class)
public @Nullable Date timestamp;
public YamlWidgetDTO() {
}
@Override
public @NonNull String getId() {
return uid == null ? "" : uid;
}
@Override
public void setId(@NonNull String id) {
uid = id;
}
@Override
public YamlElement cloneWithoutId() {
YamlWidgetDTO copy;
try {
copy = (YamlWidgetDTO) super.clone();
copy.uid = null;
return copy;
} catch (CloneNotSupportedException e) {
// Will never happen
return new YamlWidgetDTO();
}
}
@Override
public boolean isValid(@Nullable List<@NonNull String> errors, @Nullable List<@NonNull String> warnings) {
if (uid == null || uid.isBlank()) {
addToList(errors, "invalid widget: uid is missing while mandatory");
return false;
}
if (component == null || component.isBlank()) {
addToList(errors, "invalid widget \"%s\": component is missing while mandatory".formatted(uid));
return false;
}
return true;
}
private void addToList(@Nullable List<@NonNull String> list, String value) {
if (list != null) {
list.add(value);
}
}
@Override
public int hashCode() {
return Objects.hash(uid, component, config, slots, tags, props);
}
@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(uid, other.uid) && Objects.equals(component, other.component)
&& Objects.equals(config, other.config) && Objects.equals(slots, other.slots)
&& Objects.equals(tags, other.tags) && Objects.equals(props, other.props);
}
}
@@ -0,0 +1,167 @@
/*
* 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.widgets;
import static org.openhab.core.model.yaml.YamlModelUtils.isIsolatedModel;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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.ui.components.RootUIComponent;
import org.openhab.core.ui.components.UIComponentProvider;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* {@link YamlWidgetProvider} is an OSGi service that allows defining UI widgets in YAML configuration files.
* These widgets are automatically exposed to the {@link org.openhab.core.ui.components.UIComponentRegistry}
* under the {@code ui:widget} namespace.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
@Component(immediate = true, service = { UIComponentProvider.class, YamlWidgetProvider.class, YamlModelListener.class })
public class YamlWidgetProvider extends AbstractProvider<RootUIComponent>
implements UIComponentProvider, YamlModelListener<YamlWidgetDTO> {
public static final String WIDGETS_NAMESPACE = "ui:widget";
private final Logger logger = LoggerFactory.getLogger(YamlWidgetProvider.class);
private final Map<String, Collection<RootUIComponent>> widgetsMap = new ConcurrentHashMap<>();
@Deactivate
public void deactivate() {
widgetsMap.clear();
}
@Override
public String getNamespace() {
return WIDGETS_NAMESPACE;
}
@Override
public Collection<RootUIComponent> getAll() {
// Ignore isolated models
return widgetsMap.keySet().stream().filter(name -> !isIsolatedModel(name))
.map(name -> widgetsMap.getOrDefault(name, List.of())).flatMap(Collection::stream).toList();
}
@Override
public Class<YamlWidgetDTO> getElementClass() {
return YamlWidgetDTO.class;
}
@Override
public boolean isVersionSupported(int version) {
return version >= 1;
}
@Override
public boolean isDeprecated() {
return false;
}
@Override
public void addedModel(String modelName, Collection<YamlWidgetDTO> elements) {
List<RootUIComponent> added = elements.stream().map(this::mapWidget).filter(Objects::nonNull).toList();
Collection<RootUIComponent> modelWidgets = Objects
.requireNonNull(widgetsMap.computeIfAbsent(modelName, k -> new ArrayList<>()));
modelWidgets.addAll(added);
added.forEach(widget -> {
logger.debug("model {} added widget {}", modelName, widget.getUID());
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(widget);
}
});
}
@Override
public void updatedModel(String modelName, Collection<YamlWidgetDTO> elements) {
List<RootUIComponent> updated = elements.stream().map(this::mapWidget).filter(Objects::nonNull).toList();
Collection<RootUIComponent> modelWidgets = Objects
.requireNonNull(widgetsMap.computeIfAbsent(modelName, k -> new ArrayList<>()));
updated.forEach(widget -> {
String uid = widget.getUID();
modelWidgets.stream().filter(w -> w.getUID().equals(uid)).findFirst().ifPresentOrElse(oldWidget -> {
modelWidgets.remove(oldWidget);
modelWidgets.add(widget);
logger.debug("model {} updated widget {}", modelName, uid);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutUpdatedElement(oldWidget, widget);
}
}, () -> {
modelWidgets.add(widget);
logger.debug("model {} added widget {}", modelName, uid);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutAddedElement(widget);
}
});
});
}
@Override
public void removedModel(String modelName, Collection<YamlWidgetDTO> elements) {
Collection<RootUIComponent> modelWidgets = widgetsMap.getOrDefault(modelName, List.of());
elements.stream().map(YamlWidgetDTO::getId).forEach(uid -> {
modelWidgets.stream().filter(w -> w.getUID().equals(uid)).findFirst().ifPresentOrElse(oldWidget -> {
modelWidgets.remove(oldWidget);
logger.debug("model {} removed widget {}", modelName, uid);
if (!isIsolatedModel(modelName)) {
notifyListenersAboutRemovedElement(oldWidget);
}
}, () -> logger.debug("model {} widget {} not found", modelName, uid));
});
if (modelWidgets.isEmpty()) {
widgetsMap.remove(modelName);
}
}
private @Nullable RootUIComponent mapWidget(YamlWidgetDTO dto) {
String uid = dto.uid;
if (uid == null || uid.isBlank()) {
logger.warn("Skipping widget with missing uid");
return null;
}
RootUIComponent widget = new RootUIComponent(uid, dto.component != null ? dto.component : "");
if (dto.config != null) {
widget.setConfig(dto.config);
}
if (dto.slots != null) {
widget.setSlots(dto.slots);
}
if (dto.tags != null) {
widget.addTags(dto.tags);
}
if (dto.props != null) {
widget.setProps(dto.props);
}
Date timestamp = dto.timestamp;
if (timestamp != null) {
widget.setTimestamp(timestamp);
}
return widget;
}
}
@@ -24,4 +24,14 @@ import org.openhab.core.common.registry.Registry;
@NonNullByDefault @NonNullByDefault
public interface UIComponentRegistry extends Registry<RootUIComponent, String> { public interface UIComponentRegistry extends Registry<RootUIComponent, String> {
/**
* Checks whether the component with the given UID is managed (i.e. editable via the REST API).
* File-based components (e.g. loaded from YAML configuration files) are not managed and therefore read-only.
*
* @param uid the UID of the component
* @return {@code true} if the component exists in the managed (jsondb) provider, {@code false} otherwise
*/
default boolean isEditable(String uid) {
return false;
}
} }
@@ -63,4 +63,10 @@ public class UIComponentRegistryImpl extends AbstractRegistry<RootUIComponent, S
} }
super.removeProvider(provider); super.removeProvider(provider);
} }
@Override
public boolean isEditable(String uid) {
ManagedProvider<RootUIComponent, String> managed = getManagedProvider();
return managed != null && managed.get(uid) != null;
}
} }
@@ -429,6 +429,7 @@
<feature name="openhab-core-model-yaml" version="${project.version}"> <feature name="openhab-core-model-yaml" version="${project.version}">
<feature>openhab-core-base</feature> <feature>openhab-core-base</feature>
<feature>openhab-core-ui</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.model.yaml/${project.version}</bundle> <bundle>mvn:org.openhab.core.bundles/org.openhab.core.model.yaml/${project.version}</bundle>
<requirement>openhab.tp;filter:="(feature=jackson)"</requirement> <requirement>openhab.tp;filter:="(feature=jackson)"</requirement>
<feature dependency="true">openhab.tp-jackson</feature> <feature dependency="true">openhab.tp-jackson</feature>