diff --git a/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/EnrichedRootUIComponent.java b/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/EnrichedRootUIComponent.java new file mode 100644 index 000000000..ca53a579f --- /dev/null +++ b/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/EnrichedRootUIComponent.java @@ -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; + } +} diff --git a/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/internal/UIResource.java b/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/internal/UIResource.java index 75333ce56..791c966cf 100644 --- a/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/internal/UIResource.java +++ b/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/internal/UIResource.java @@ -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 components = registry.getAll().stream(); + Stream 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 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(); } diff --git a/bundles/org.openhab.core.model.yaml/pom.xml b/bundles/org.openhab.core.model.yaml/pom.xml index 1dbf44803..26563ef29 100644 --- a/bundles/org.openhab.core.model.yaml/pom.xml +++ b/bundles/org.openhab.core.model.yaml/pom.xml @@ -45,5 +45,10 @@ org.openhab.core.automation.module.script.rulesupport ${project.version} + + org.openhab.core.bundles + org.openhab.core.ui + ${project.version} + 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 e75bc3e84..7b8fd2d6e 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 @@ -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] "; diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/pages/YamlPageDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/pages/YamlPageDTO.java new file mode 100644 index 000000000..78a3a60d5 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/pages/YamlPageDTO.java @@ -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 config; + public Map> slots; + public Set 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); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/pages/YamlPageProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/pages/YamlPageProvider.java new file mode 100644 index 000000000..2bb78baaf --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/pages/YamlPageProvider.java @@ -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 + implements UIComponentProvider, YamlModelListener { + + public static final String PAGES_NAMESPACE = "ui:page"; + + private final Logger logger = LoggerFactory.getLogger(YamlPageProvider.class); + + private final Map> pagesMap = new ConcurrentHashMap<>(); + + @Deactivate + public void deactivate() { + pagesMap.clear(); + } + + @Override + public String getNamespace() { + return PAGES_NAMESPACE; + } + + @Override + public Collection 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 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 elements) { + List added = elements.stream().map(this::mapPage).filter(Objects::nonNull).toList(); + Collection 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 elements) { + List updated = elements.stream().map(this::mapPage).filter(Objects::nonNull).toList(); + Collection 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 elements) { + Collection 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; + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/FlexibleDateDeserializer.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/FlexibleDateDeserializer.java new file mode 100644 index 000000000..a522da8a1 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/FlexibleDateDeserializer.java @@ -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: + *
    + *
  • Epoch milliseconds (numeric)
  • + *
  • ISO 8601 (e.g. {@code 2021-03-08T19:41:03Z})
  • + *
  • Legacy openHAB UI format (e.g. {@code Mar 8, 2021, 7:41:03 PM})
  • + *
+ * + * @author Jimmy Tanagra - Initial contribution + */ +public class FlexibleDateDeserializer extends StdDeserializer { + + private static final long serialVersionUID = 1L; + + private static final List 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'"); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/widgets/YamlWidgetDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/widgets/YamlWidgetDTO.java new file mode 100644 index 000000000..0e8940354 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/widgets/YamlWidgetDTO.java @@ -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 config; + public Map> slots; + public Set 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); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/widgets/YamlWidgetProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/widgets/YamlWidgetProvider.java new file mode 100644 index 000000000..3571c32dd --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/widgets/YamlWidgetProvider.java @@ -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 + implements UIComponentProvider, YamlModelListener { + + public static final String WIDGETS_NAMESPACE = "ui:widget"; + + private final Logger logger = LoggerFactory.getLogger(YamlWidgetProvider.class); + + private final Map> widgetsMap = new ConcurrentHashMap<>(); + + @Deactivate + public void deactivate() { + widgetsMap.clear(); + } + + @Override + public String getNamespace() { + return WIDGETS_NAMESPACE; + } + + @Override + public Collection 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 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 elements) { + List added = elements.stream().map(this::mapWidget).filter(Objects::nonNull).toList(); + Collection 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 elements) { + List updated = elements.stream().map(this::mapWidget).filter(Objects::nonNull).toList(); + Collection 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 elements) { + Collection 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; + } +} diff --git a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/components/UIComponentRegistry.java b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/components/UIComponentRegistry.java index 8619d86e6..8464d2455 100644 --- a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/components/UIComponentRegistry.java +++ b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/components/UIComponentRegistry.java @@ -24,4 +24,14 @@ import org.openhab.core.common.registry.Registry; @NonNullByDefault public interface UIComponentRegistry extends Registry { + /** + * 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; + } } diff --git a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentRegistryImpl.java b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentRegistryImpl.java index b229af6e0..696b8c946 100644 --- a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentRegistryImpl.java +++ b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/components/UIComponentRegistryImpl.java @@ -63,4 +63,10 @@ public class UIComponentRegistryImpl extends AbstractRegistry managed = getManagedProvider(); + return managed != null && managed.get(uid) != null; + } } diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml index fab5add6b..303ca0c1d 100644 --- a/features/karaf/openhab-core/src/main/feature/feature.xml +++ b/features/karaf/openhab-core/src/main/feature/feature.xml @@ -429,6 +429,7 @@ openhab-core-base + openhab-core-ui mvn:org.openhab.core.bundles/org.openhab.core.model.yaml/${project.version} openhab.tp;filter:="(feature=jackson)" openhab.tp-jackson