mirror of
https://github.com/danieldemus/openhab-core.git
synced 2026-07-31 05:24:26 +02:00
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:
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
-12
@@ -44,6 +44,7 @@ import org.openhab.core.common.registry.RegistryChangeListener;
|
||||
import org.openhab.core.io.rest.RESTConstants;
|
||||
import org.openhab.core.io.rest.RESTResource;
|
||||
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.ui.components.RootUIComponent;
|
||||
import org.openhab.core.ui.components.UIComponentRegistry;
|
||||
@@ -127,18 +128,20 @@ public class UIResource implements RESTResource {
|
||||
@Path("/components/{namespace}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@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,
|
||||
@QueryParam("summary") @Parameter(description = "summary fields only") @Nullable Boolean summary) {
|
||||
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) {
|
||||
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
|
||||
Set<String> tags = c.getTags();
|
||||
if (tags != null) {
|
||||
component.addTags(c.getTags());
|
||||
component.addTags(tags);
|
||||
}
|
||||
@Nullable
|
||||
Date timestamp = c.getTimestamp();
|
||||
@@ -178,7 +181,7 @@ public class UIResource implements RESTResource {
|
||||
@Path("/components/{namespace}/{componentUID}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@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") })
|
||||
public Response getComponentByUID(@PathParam("namespace") String namespace,
|
||||
@PathParam("componentUID") String componentUID) {
|
||||
@@ -187,7 +190,7 @@ public class UIResource implements RESTResource {
|
||||
if (component == null) {
|
||||
return Response.status(Status.NOT_FOUND).build();
|
||||
}
|
||||
return Response.ok(component).build();
|
||||
return Response.ok(new EnrichedRootUIComponent(component, registry.isEditable(componentUID))).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -197,12 +200,12 @@ public class UIResource implements RESTResource {
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(operationId = "addUIComponentToNamespace", summary = "Add a UI component in the specified namespace.", security = {
|
||||
@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) {
|
||||
UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace);
|
||||
component.updateTimestamp();
|
||||
RootUIComponent createdComponent = registry.add(component);
|
||||
return Response.ok(createdComponent).build();
|
||||
return Response.ok(new EnrichedRootUIComponent(createdComponent, true)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -212,8 +215,9 @@ public class UIResource implements RESTResource {
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(operationId = "updateUIComponentInNamespace", summary = "Update a specific UI component in the specified namespace.", security = {
|
||||
@SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = {
|
||||
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = RootUIComponent.class))),
|
||||
@ApiResponse(responseCode = "404", description = "Component not found") })
|
||||
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = EnrichedRootUIComponent.class))),
|
||||
@ApiResponse(responseCode = "404", description = "Component not found"),
|
||||
@ApiResponse(responseCode = "405", description = "Component is not editable.") })
|
||||
public Response updateComponent(@PathParam("namespace") String namespace,
|
||||
@PathParam("componentUID") String componentUID, RootUIComponent component) {
|
||||
UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace);
|
||||
@@ -221,13 +225,17 @@ public class UIResource implements RESTResource {
|
||||
if (existingComponent == null) {
|
||||
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())) {
|
||||
throw new InvalidParameterException(
|
||||
"The component UID in the body of the request should match the UID in the URL");
|
||||
}
|
||||
component.updateTimestamp();
|
||||
registry.update(component);
|
||||
return Response.ok(component).build();
|
||||
return Response.ok(new EnrichedRootUIComponent(component, true)).build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@@ -237,7 +245,8 @@ public class UIResource implements RESTResource {
|
||||
@Operation(operationId = "removeUIComponentFromNamespace", summary = "Remove a specific UI component in the specified namespace.", security = {
|
||||
@SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = {
|
||||
@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,
|
||||
@PathParam("componentUID") String componentUID) {
|
||||
UIComponentRegistry registry = componentRegistryFactory.getRegistry(namespace);
|
||||
@@ -245,6 +254,10 @@ public class UIResource implements RESTResource {
|
||||
if (component == null) {
|
||||
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);
|
||||
return Response.ok().build();
|
||||
}
|
||||
|
||||
@@ -45,5 +45,10 @@
|
||||
<artifactId>org.openhab.core.automation.module.script.rulesupport</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.ui</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
+6
-1
@@ -45,10 +45,12 @@ import org.openhab.core.model.yaml.YamlElementName;
|
||||
import org.openhab.core.model.yaml.YamlModelListener;
|
||||
import org.openhab.core.model.yaml.YamlModelRepository;
|
||||
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.YamlRuleTemplateDTO;
|
||||
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.widgets.YamlWidgetDTO;
|
||||
import org.openhab.core.service.WatchService;
|
||||
import org.openhab.core.service.WatchService.Kind;
|
||||
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 - Added methods addElementsToBeGenerated, generateFileFormat, createIsolatedModel and
|
||||
* removeIsolatedModel
|
||||
* @author Jimmy Tanagra - Add YamlPageDTO and YamlWidgetDTO
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(immediate = true)
|
||||
@@ -97,7 +100,9 @@ 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(YamlPageDTO.class), // "pages"
|
||||
getElementName(YamlWidgetDTO.class) // "widgets"
|
||||
);
|
||||
|
||||
private static final String UNWANTED_EXCEPTION_TEXT = "at [Source: UNKNOWN; byte offset: #UNKNOWN] ";
|
||||
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
+165
@@ -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;
|
||||
}
|
||||
}
|
||||
+89
@@ -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'");
|
||||
}
|
||||
}
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
+167
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -24,4 +24,14 @@ import org.openhab.core.common.registry.Registry;
|
||||
@NonNullByDefault
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -63,4 +63,10 @@ public class UIComponentRegistryImpl extends AbstractRegistry<RootUIComponent, S
|
||||
}
|
||||
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>openhab-core-base</feature>
|
||||
<feature>openhab-core-ui</feature>
|
||||
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.model.yaml/${project.version}</bundle>
|
||||
<requirement>openhab.tp;filter:="(feature=jackson)"</requirement>
|
||||
<feature dependency="true">openhab.tp-jackson</feature>
|
||||
|
||||
Reference in New Issue
Block a user