diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java index 142f274b45..ce91d748fd 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java @@ -160,7 +160,7 @@ public class FileFormatResource implements RESTResource { MyItem: type: Switch label: Label - category: icon + icon: icon groups: - Group1 - Group2 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 a7a0ab3ce0..f1fa56660c 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 @@ -40,6 +40,7 @@ import org.openhab.core.model.yaml.YamlElement; 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.semantics.YamlSemanticTagDTO; import org.openhab.core.model.yaml.internal.things.YamlThingDTO; import org.openhab.core.service.WatchService; @@ -86,7 +87,8 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener, private static final String READ_ONLY = "readOnly"; private static final Set KNOWN_ELEMENTS = Set.of( // getElementName(YamlSemanticTagDTO.class), // "tags" - getElementName(YamlThingDTO.class) // "things" + getElementName(YamlThingDTO.class), // "things" + getElementName(YamlItemDTO.class) // "items" ); 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/items/YamlChannelLinkProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlChannelLinkProvider.java new file mode 100644 index 0000000000..9d7267cafe --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlChannelLinkProvider.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2010-2025 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.items; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.common.registry.AbstractProvider; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.items.ItemProvider; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.link.ItemChannelLink; +import org.openhab.core.thing.link.ItemChannelLinkProvider; +import org.openhab.core.thing.profiles.ProfileTypeUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class serves as a provider for all item channel links that is found within YAML files. + * It is filled with content by the {@link YamlItemProvider}, which cannot itself implement the + * {@link ItemChannelLinkProvider} interface as it already implements {@link ItemProvider}, + * which would lead to duplicate methods. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = { ItemChannelLinkProvider.class, YamlChannelLinkProvider.class }) +public class YamlChannelLinkProvider extends AbstractProvider implements ItemChannelLinkProvider { + + private final Logger logger = LoggerFactory.getLogger(YamlChannelLinkProvider.class); + + // Map the channel links to each channel UID and then to each item name and finally to each model name + private Map>> itemsChannelLinksMap = new ConcurrentHashMap<>(); + + @Override + public Collection getAll() { + return itemsChannelLinksMap.values().stream().flatMap(m -> m.values().stream()) + .flatMap(m -> m.values().stream()).toList(); + } + + public Collection getAllFromModel(String modelName) { + return itemsChannelLinksMap.getOrDefault(modelName, Map.of()).values().stream() + .flatMap(m -> m.values().stream()).toList(); + } + + public void updateItemChannelLinks(String modelName, String itemName, Map channelLinks) { + Map> channelLinksMap = Objects + .requireNonNull(itemsChannelLinksMap.computeIfAbsent(modelName, k -> new ConcurrentHashMap<>())); + // Create a HashMap with an initial capacity of 2 (the default is 16) to save memory because most items have + // only one channel. A capacity of 2 is enough to avoid resizing the HashMap in most cases, whereas 1 would + // trigger a resize as soon as one element is added. + Map links = Objects + .requireNonNull(channelLinksMap.computeIfAbsent(itemName, k -> new ConcurrentHashMap<>(2))); + + Set linksToBeRemoved = new HashSet<>(links.keySet()); + + for (Map.Entry entry : channelLinks.entrySet()) { + String channelUID = entry.getKey(); + Configuration configuration = entry.getValue(); + + ChannelUID channelUIDObject; + try { + channelUIDObject = new ChannelUID(channelUID); + } catch (IllegalArgumentException e) { + logger.warn("Invalid channel UID '{}' in channel link for item '{}'!", channelUID, itemName, e); + continue; + } + + // Fix the configuration in case a profile is defined without any scope + if (configuration.containsKey("profile") && configuration.get("profile") instanceof String profile + && profile.indexOf(":") == -1) { + String fullProfile = ProfileTypeUID.SYSTEM_SCOPE + ":" + profile; + configuration.put("profile", fullProfile); + logger.info( + "Profile '{}' for channel '{}' is missing the scope prefix, assuming the correct UID is '{}'. Check your configuration.", + profile, channelUID, fullProfile); + } + + ItemChannelLink itemChannelLink = new ItemChannelLink(itemName, channelUIDObject, configuration); + + linksToBeRemoved.remove(channelUIDObject); + ItemChannelLink oldLink = links.get(channelUIDObject); + if (oldLink == null) { + links.put(channelUIDObject, itemChannelLink); + logger.debug("notify added item channel link {}", itemChannelLink.getUID()); + notifyListenersAboutAddedElement(itemChannelLink); + } else if (!YamlElementUtils.equalsConfig(configuration.getProperties(), + oldLink.getConfiguration().getProperties())) { + links.put(channelUIDObject, itemChannelLink); + logger.debug("notify updated item channel link {}", itemChannelLink.getUID()); + notifyListenersAboutUpdatedElement(oldLink, itemChannelLink); + } + } + + linksToBeRemoved.forEach(uid -> { + ItemChannelLink link = links.remove(uid); + if (link != null) { + logger.debug("notify removed item channel link {}", link.getUID()); + notifyListenersAboutRemovedElement(link); + } + }); + if (links.isEmpty()) { + channelLinksMap.remove(itemName); + } + if (channelLinksMap.isEmpty()) { + itemsChannelLinksMap.remove(modelName); + } + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlGroupDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlGroupDTO.java new file mode 100644 index 0000000000..3a75e3a475 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlGroupDTO.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2010-2025 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.items; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; + +/** + * The {@link YamlGroupDTO} is a data transfer object used to serialize the details of a group item + * in a YAML configuration file. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlGroupDTO { + + private static final String DEFAULT_FUNCTION = "EQUALITY"; + private static final Set VALID_FUNCTIONS = Set.of("AND", "OR", "NAND", "NOR", "XOR", "COUNT", "AVG", + "MEDIAN", "SUM", "MIN", "MAX", "LATEST", "EARLIEST", DEFAULT_FUNCTION); + + public String type; + public String dimension; + public String function; + public List<@NonNull String> parameters; + + public YamlGroupDTO() { + } + + public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) { + boolean ok = true; + if (!YamlElementUtils.isValidItemType(type)) { + errors.add("invalid value \"%s\" for \"type\" field in group".formatted(type)); + ok = false; + } else if (YamlElementUtils.isNumberItemType(type)) { + if (!YamlElementUtils.isValidItemDimension(dimension)) { + errors.add("invalid value \"%s\" for \"dimension\" field in group".formatted(dimension)); + ok = false; + } + } else if (dimension != null) { + warnings.add("\"dimension\" field in group ignored as type is not Number"); + } + if (!VALID_FUNCTIONS.contains(getFunction())) { + errors.add("invalid value \"%s\" for \"function\" field".formatted(function)); + ok = false; + } + return ok; + } + + public @Nullable String getBaseType() { + return YamlElementUtils.getItemTypeWithDimension(type, dimension); + } + + public String getFunction() { + return function != null ? function.toUpperCase() : DEFAULT_FUNCTION; + } + + @Override + public int hashCode() { + return Objects.hash(getBaseType(), getFunction()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlGroupDTO other = (YamlGroupDTO) obj; + return Objects.equals(getBaseType(), other.getBaseType()) && Objects.equals(getFunction(), other.getFunction()) + && YamlElementUtils.equalsListStrings(parameters, other.parameters); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java new file mode 100644 index 0000000000..de14f30a37 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2010-2025 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.items; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.AbstractUID; +import org.openhab.core.items.GroupItem; +import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlElementName; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; + +/** + * The {@link YamlItemDTO} is a data transfer object used to serialize an item in a YAML configuration file. + * + * @author Laurent Garnier - Initial contribution + */ +@YamlElementName("items") +public class YamlItemDTO implements YamlElement, Cloneable { + + private static final Pattern ID_PATTERN = Pattern.compile("[a-zA-Z0-9_][a-zA-Z0-9_-]*"); + private static final Pattern CHANNEL_ID_PATTERN = Pattern + .compile("[a-zA-Z0-9_][a-zA-Z0-9_-]*(#[a-zA-Z0-9_][a-zA-Z0-9_-]*)?"); + + public String name; + public String type; + public String dimension; + public YamlGroupDTO group; + public String label; + public String icon; + public String format; + public String unit; + public Boolean autoupdate; + public List<@NonNull String> groups; + public Set<@NonNull String> tags; + public String channel; + public Map<@NonNull String, @NonNull Map<@NonNull String, @NonNull Object>> channels; + public Map<@NonNull String, @NonNull YamlMetadataDTO> metadata; + + public YamlItemDTO() { + } + + @Override + public @NonNull String getId() { + return name == null ? "" : name; + } + + @Override + public void setId(@NonNull String id) { + name = id; + } + + @Override + public YamlElement cloneWithoutId() { + YamlItemDTO copy; + try { + copy = (YamlItemDTO) super.clone(); + copy.name = null; + return copy; + } catch (CloneNotSupportedException e) { + // Will never happen + return new YamlItemDTO(); + } + } + + @Override + public boolean isValid(@Nullable List<@NonNull String> errors, @Nullable List<@NonNull String> warnings) { + // Check that name is present + if (name == null || name.isBlank()) { + addToList(errors, "invalid item: name missing while mandatory"); + return false; + } + boolean ok = true; + if (!ID_PATTERN.matcher(name).matches()) { + addToList(errors, "invalid item: name \"%s\" not matching the expected syntax %s".formatted(name, + ID_PATTERN.pattern())); + ok = false; + } + List subErrors = new ArrayList<>(); + List subWarnings = new ArrayList<>(); + if (type == null || type.isBlank()) { + addToList(errors, "invalid item \"%s\": \"type\" field missing while mandatory".formatted(name)); + ok = false; + } else if (GroupItem.TYPE.equalsIgnoreCase(type)) { + if (dimension != null) { + addToList(warnings, "item \"%s\": \"dimension\" field ignored as type is Group".formatted(name)); + } + if (group != null) { + ok &= group.isValid(subErrors, subWarnings); + subErrors.forEach(error -> { + addToList(errors, "invalid item \"%s\": %s".formatted(name, error)); + }); + subWarnings.forEach(warning -> { + addToList(warnings, "item \"%s\": %s".formatted(name, warning)); + }); + } + } else { + if (group != null) { + addToList(warnings, "item \"%s\": \"group\" field ignored as type is not Group".formatted(name)); + } + if (!YamlElementUtils.isValidItemType(type)) { + addToList(errors, "invalid item \"%s\": invalid value \"%s\" for \"type\" field".formatted(name, type)); + ok = false; + } else if (YamlElementUtils.isNumberItemType(type)) { + if (!YamlElementUtils.isValidItemDimension(dimension)) { + addToList(errors, "invalid item \"%s\": invalid value \"%s\" for \"dimension\" field" + .formatted(name, dimension)); + ok = false; + } + } else if (dimension != null) { + addToList(warnings, + "item \"%s\": \"dimension\" field ignored as type is not Number".formatted(name, dimension)); + } + } + if (icon != null) { + subErrors.clear(); + ok &= isValidIcon(icon, subErrors); + subErrors.forEach(error -> { + addToList(errors, "invalid item \"%s\": %s".formatted(name, error)); + }); + } + if (groups != null) { + for (String gr : groups) { + if (!ID_PATTERN.matcher(gr).matches()) { + addToList(errors, + "invalid item \"%s\": value \"%s\" for group name not matching the expected syntax %s" + .formatted(name, gr, ID_PATTERN.pattern())); + ok = false; + } + } + } + if (channel != null) { + subErrors.clear(); + ok &= isValidChannel(channel, subErrors); + subErrors.forEach(error -> { + addToList(errors, "invalid item \"%s\": %s".formatted(name, error)); + }); + } + if (channels != null) { + for (String ch : channels.keySet()) { + subErrors.clear(); + ok &= isValidChannel(ch, subErrors); + subErrors.forEach(error -> { + addToList(errors, "invalid item \"%s\": %s".formatted(name, error)); + }); + } + } + if (metadata != null) { + for (String namespace : metadata.keySet()) { + if (!ID_PATTERN.matcher(namespace).matches()) { + addToList(errors, "invalid item \"%s\": metadata \"%s\" not matching the expected syntax %s" + .formatted(name, namespace, ID_PATTERN.pattern())); + ok = false; + } + } + YamlMetadataDTO md = metadata.get("autoupdate"); + if (md != null && autoupdate != null) { + addToList(warnings, + "item \"%s\": \"autoupdate\" field is redundant with \"autoupdate\" metadata; value \"%s\" will be considered" + .formatted(name, md.getValue())); + } + md = metadata.get("unit"); + if (md != null && unit != null) { + addToList(warnings, + "item \"%s\": \"unit\" field is redundant with \"unit\" metadata; value \"%s\" will be considered" + .formatted(name, md.getValue())); + } + md = metadata.get("stateDescription"); + Map<@NonNull String, @NonNull Object> mdConfig = md == null ? null : md.config; + Object pattern = mdConfig == null ? null : mdConfig.get("pattern"); + if (pattern != null && format != null) { + addToList(warnings, + "item \"%s\": \"format\" field is redundant with pattern in \"stateDescription\" metadata; \"%s\" will be considered" + .formatted(name, pattern)); + } + } + return ok; + } + + private boolean isValidIcon(String icon, List<@NonNull String> errors) { + boolean ok = true; + String[] segments = icon.split(AbstractUID.SEPARATOR); + int nb = segments.length; + if (nb > 3) { + errors.add("too many segments in value \"%s\" for \"icon\" field; maximum 3 is expected".formatted(icon)); + ok = false; + nb = 3; + } + for (int i = 0; i < nb; i++) { + String segment = segments[i]; + if (!ID_PATTERN.matcher(segment).matches()) { + errors.add("segment \"%s\" in \"icon\" field not matching the expected syntax %s".formatted(segment, + ID_PATTERN.pattern())); + ok = false; + } + } + return ok; + } + + private boolean isValidChannel(String channelUID, List<@NonNull String> errors) { + boolean ok = true; + String[] segments = channelUID.split(AbstractUID.SEPARATOR); + int nb = segments.length; + if (nb < 4) { + errors.add("not enough segments in channel UID \"%s\"; minimum 4 is expected".formatted(channelUID)); + ok = false; + } + String segment; + for (int i = 0; i < (nb - 1); i++) { + segment = segments[i]; + if (!ID_PATTERN.matcher(segment).matches()) { + errors.add("segment \"%s\" in channel UID \"%s\" not matching the expected syntax %s".formatted(segment, + channelUID, ID_PATTERN.pattern())); + ok = false; + } + } + segment = segments[nb - 1]; + if (!CHANNEL_ID_PATTERN.matcher(segment).matches()) { + errors.add("last segment \"%s\" in channel UID \"%s\" not matching the expected syntax %s" + .formatted(segment, channelUID, CHANNEL_ID_PATTERN.pattern())); + ok = false; + } + return ok; + } + + private void addToList(@Nullable List<@NonNull String> list, String value) { + if (list != null) { + list.add(value); + } + } + + public @Nullable String getType() { + return YamlElementUtils.getItemTypeWithDimension(type, dimension); + } + + @Override + public int hashCode() { + return Objects.hash(name, getType(), label, icon); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlItemDTO other = (YamlItemDTO) obj; + return Objects.equals(name, other.name) && Objects.equals(getType(), other.getType()) + && Objects.equals(group, other.group) && Objects.equals(label, other.label) + && Objects.equals(icon, other.icon) && Objects.equals(format, other.format) + && Objects.equals(unit, other.unit) && Objects.equals(autoupdate, other.autoupdate) + && YamlElementUtils.equalsListStrings(groups, other.groups) + && YamlElementUtils.equalsSetStrings(tags, other.tags) && Objects.equals(channel, other.channel) + && equalsChannels(channels, other.channels) && equalsMetadata(metadata, other.metadata); + } + + private boolean equalsChannels(@Nullable Map<@NonNull String, @NonNull Map<@NonNull String, @NonNull Object>> first, + @Nullable Map<@NonNull String, @NonNull Map<@NonNull String, @NonNull Object>> second) { + if (first != null && second != null) { + if (first.size() != second.size()) { + return false; + } else { + return first.entrySet().stream() + .allMatch(e -> YamlElementUtils.equalsConfig(e.getValue(), second.get(e.getKey()))); + } + } else { + return first == null && second == null; + } + } + + private boolean equalsMetadata(@Nullable Map<@NonNull String, @NonNull YamlMetadataDTO> first, + @Nullable Map<@NonNull String, @NonNull YamlMetadataDTO> second) { + if (first != null && second != null) { + if (first.size() != second.size()) { + return false; + } else { + return first.entrySet().stream().allMatch(e -> e.getValue().equals(second.get(e.getKey()))); + } + } else { + return first == null && second == null; + } + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemProvider.java new file mode 100644 index 0000000000..726906591a --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemProvider.java @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2010-2025 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.items; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +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.config.core.Configuration; +import org.openhab.core.items.GenericItem; +import org.openhab.core.items.GroupItem; +import org.openhab.core.items.Item; +import org.openhab.core.items.ItemProvider; +import org.openhab.core.items.dto.GroupFunctionDTO; +import org.openhab.core.items.dto.ItemDTOMapper; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.model.yaml.YamlModelListener; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link YamlItemProvider} is an OSGi service, that allows to define items in YAML configuration files. + * Files can be added, updated or removed at runtime. + * These items are automatically exposed to the {@link org.openhab.core.items.ItemRegistry}. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = { ItemProvider.class, YamlItemProvider.class, YamlModelListener.class }) +public class YamlItemProvider extends AbstractProvider implements ItemProvider, YamlModelListener { + + private final Logger logger = LoggerFactory.getLogger(YamlItemProvider.class); + + private CoreItemFactory itemFactory; + private final YamlChannelLinkProvider itemChannelLinkProvider; + private final YamlMetadataProvider metaDataProvider; + + private final Map> itemsMap = new ConcurrentHashMap<>(); + + @Activate + public YamlItemProvider(final @Reference CoreItemFactory itemFactory, + final @Reference YamlChannelLinkProvider itemChannelLinkProvider, + final @Reference YamlMetadataProvider metaDataProvider, Map properties) { + this.itemFactory = itemFactory; + this.itemChannelLinkProvider = itemChannelLinkProvider; + this.metaDataProvider = metaDataProvider; + } + + @Deactivate + public void deactivate() { + itemsMap.clear(); + } + + @Override + public Collection getAll() { + return itemsMap.values().stream().flatMap(list -> list.stream()).toList(); + } + + public Collection getAllFromModel(String modelName) { + return itemsMap.getOrDefault(modelName, List.of()); + } + + @Override + public Class getElementClass() { + return YamlItemDTO.class; + } + + @Override + public boolean isVersionSupported(int version) { + return version >= 2; + } + + @Override + public boolean isDeprecated() { + return false; + } + + @Override + public void addedModel(String modelName, Collection elements) { + Map added = new LinkedHashMap<>(); + elements.forEach(elt -> { + Item item = mapItem(elt); + if (item != null) { + added.put(item, elt); + } + }); + + Collection modelItems = Objects + .requireNonNull(itemsMap.computeIfAbsent(modelName, k -> new ArrayList<>())); + modelItems.addAll(added.keySet()); + + added.forEach((item, itemDTO) -> { + String name = item.getName(); + logger.debug("model {} added item {}", modelName, name); + notifyListenersAboutAddedElement(item); + processChannelLinks(modelName, name, itemDTO); + processMetadata(modelName, name, itemDTO); + }); + } + + @Override + public void updatedModel(String modelName, Collection elements) { + Map updated = new LinkedHashMap<>(); + elements.forEach(elt -> { + Item item = mapItem(elt); + if (item != null) { + updated.put(item, elt); + } + }); + + Collection modelItems = Objects + .requireNonNull(itemsMap.computeIfAbsent(modelName, k -> new ArrayList<>())); + updated.forEach((item, itemDTO) -> { + String name = item.getName(); + modelItems.stream().filter(i -> i.getName().equals(name)).findFirst().ifPresentOrElse(oldItem -> { + modelItems.remove(oldItem); + modelItems.add(item); + logger.debug("model {} updated item {}", modelName, name); + notifyListenersAboutUpdatedElement(oldItem, item); + }, () -> { + modelItems.add(item); + logger.debug("model {} added item {}", modelName, name); + notifyListenersAboutAddedElement(item); + }); + processChannelLinks(modelName, name, itemDTO); + processMetadata(modelName, name, itemDTO); + }); + } + + @Override + public void removedModel(String modelName, Collection elements) { + List removed = elements.stream().map(elt -> mapItem(elt)).filter(Objects::nonNull).toList(); + + Collection modelItems = itemsMap.getOrDefault(modelName, List.of()); + removed.forEach(item -> { + String name = item.getName(); + modelItems.stream().filter(i -> i.getName().equals(name)).findFirst().ifPresentOrElse(oldItem -> { + modelItems.remove(oldItem); + logger.debug("model {} removed item {}", modelName, name); + notifyListenersAboutRemovedElement(oldItem); + }, () -> logger.debug("model {} item {} not found", modelName, name)); + processChannelLinks(modelName, name, null); + processMetadata(modelName, name, null); + }); + + if (modelItems.isEmpty()) { + itemsMap.remove(modelName); + } + } + + private @Nullable Item mapItem(YamlItemDTO itemDTO) { + String name = itemDTO.name; + Item item; + try { + if (GroupItem.TYPE.equalsIgnoreCase(itemDTO.type)) { + YamlGroupDTO groupDTO = itemDTO.group; + if (groupDTO != null) { + Item baseItem = createItemOfType(groupDTO.getBaseType(), name); + if (baseItem != null) { + GroupFunctionDTO groupFunctionDto = new GroupFunctionDTO(); + groupFunctionDto.name = groupDTO.getFunction(); + groupFunctionDto.params = groupDTO.parameters != null + ? groupDTO.parameters.toArray(new String[0]) + : new String[0]; + item = new GroupItem(name, baseItem, ItemDTOMapper.mapFunction(baseItem, groupFunctionDto)); + } else { + item = new GroupItem(name); + } + } else { + item = new GroupItem(name); + } + } else { + item = createItemOfType(itemDTO.getType(), name); + } + } catch (IllegalArgumentException e) { + logger.warn("Error creating item '{}', item will be ignored: {}", name, e.getMessage()); + item = null; + } + + if (item instanceof GenericItem genericItem) { + genericItem.setLabel(itemDTO.label); + genericItem.setCategory(itemDTO.icon); + + if (itemDTO.tags != null) { + for (String tag : itemDTO.tags) { + genericItem.addTag(tag); + } + } + if (itemDTO.groups != null) { + for (String groupName : itemDTO.groups) { + genericItem.addGroupName(groupName); + } + } + } + + return item; + } + + private @Nullable Item createItemOfType(@Nullable String itemType, String itemName) { + if (itemType == null) { + return null; + } + + Item item = itemFactory.createItem(itemType, itemName); + if (item != null) { + logger.debug("Created item '{}' of type '{}'", itemName, itemType); + return item; + } + + logger.warn("CoreItemFactory cannot create item '{}' of type '{}'", itemName, itemType); + return null; + } + + private void processChannelLinks(String modelName, String itemName, @Nullable YamlItemDTO itemDTO) { + Map channelLinks = new HashMap<>(2); + if (itemDTO != null) { + if (itemDTO.channel != null) { + channelLinks.put(itemDTO.channel, new Configuration()); + } + if (itemDTO.channels != null) { + itemDTO.channels.forEach((channelUID, config) -> { + channelLinks.put(channelUID, new Configuration(config)); + }); + } + } + try { + itemChannelLinkProvider.updateItemChannelLinks(modelName, itemName, channelLinks); + } catch (Exception e) { + logger.warn("Channel links configuration of item '{}' could not be parsed correctly.", itemName, e); + } + } + + private void processMetadata(String modelName, String itemName, @Nullable YamlItemDTO itemDTO) { + Map metadata = new HashMap<>(); + if (itemDTO != null) { + boolean hasAutoUpdateMetadata = false; + boolean hasUnitMetadata = false; + boolean hasStateDescriptionMetadata = false; + if (itemDTO.metadata != null) { + for (Map.Entry entry : itemDTO.metadata.entrySet()) { + if ("autoupdate".equals(entry.getKey())) { + hasAutoUpdateMetadata = true; + } else if ("unit".equals(entry.getKey())) { + hasUnitMetadata = true; + } else if ("stateDescription".equals(entry.getKey())) { + hasStateDescriptionMetadata = true; + } + Map config = entry.getValue().config; + if (itemDTO.format != null && "stateDescription".equals(entry.getKey()) + && (entry.getValue().config == null || entry.getValue().config.get("pattern") == null)) { + config = new HashMap<>(); + if (entry.getValue().config != null) { + for (Map.Entry confEntry : entry.getValue().config.entrySet()) { + config.put(confEntry.getKey(), confEntry.getValue()); + } + config.put("pattern", itemDTO.format); + } + } + YamlMetadataDTO mdDTO = new YamlMetadataDTO(); + mdDTO.value = entry.getValue().value; + mdDTO.config = config; + metadata.put(entry.getKey(), mdDTO); + } + } + if (!hasAutoUpdateMetadata && itemDTO.autoupdate != null) { + YamlMetadataDTO mdDTO = new YamlMetadataDTO(); + mdDTO.value = String.valueOf(itemDTO.autoupdate); + metadata.put("autoupdate", mdDTO); + } + if (!hasUnitMetadata && itemDTO.unit != null) { + YamlMetadataDTO mdDTO = new YamlMetadataDTO(); + mdDTO.value = itemDTO.unit; + metadata.put("unit", mdDTO); + } + if (!hasStateDescriptionMetadata && itemDTO.format != null) { + YamlMetadataDTO mdDTO = new YamlMetadataDTO(); + mdDTO.config = Map.of("pattern", itemDTO.format); + metadata.put("stateDescription", mdDTO); + } + } + metaDataProvider.updateMetadata(modelName, itemName, metadata); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataDTO.java new file mode 100644 index 0000000000..f4a6f7c39f --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataDTO.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2025 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.items; + +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; + +/** + * The {@link YamlMetadataDTO} is a data transfer object used to serialize a metadata for a particular namespace + * in a YAML configuration file. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlMetadataDTO { + + public String value; + public Map<@NonNull String, @NonNull Object> config; + + public YamlMetadataDTO() { + } + + public @NonNull String getValue() { + return value == null ? "" : value; + } + + @Override + public int hashCode() { + return Objects.hash(getValue()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + YamlMetadataDTO other = (YamlMetadataDTO) obj; + return Objects.equals(getValue(), other.getValue()) && YamlElementUtils.equalsConfig(config, other.config); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataProvider.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataProvider.java new file mode 100644 index 0000000000..5c53112f85 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlMetadataProvider.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2010-2025 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.items; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.common.registry.AbstractProvider; +import org.openhab.core.items.ItemProvider; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.items.MetadataProvider; +import org.openhab.core.model.yaml.internal.util.YamlElementUtils; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class serves as a provider for all metadata that is found within YAML files. + * It is filled with content by the {@link YamlItemProvider}, which cannot itself implement the + * {@link MetadataProvider} interface as it already implements {@link ItemProvider}, which would lead to duplicate + * methods. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(service = { MetadataProvider.class, YamlMetadataProvider.class }) +public class YamlMetadataProvider extends AbstractProvider implements MetadataProvider { + + private final Logger logger = LoggerFactory.getLogger(YamlMetadataProvider.class); + + // Map the metadata to each namespace and then to each item name and finally to each model name + private Map>> metadataMap = new ConcurrentHashMap<>(); + + @Override + public Collection getAll() { + return metadataMap.values().stream().flatMap(m -> m.values().stream()).flatMap(m -> m.values().stream()) + .toList(); + } + + public Collection getAllFromModel(String modelName) { + return metadataMap.getOrDefault(modelName, Map.of()).values().stream().flatMap(m -> m.values().stream()) + .toList(); + } + + public void updateMetadata(String modelName, String itemName, Map metadata) { + Map> itemsMetadataMap = Objects + .requireNonNull(metadataMap.computeIfAbsent(modelName, k -> new ConcurrentHashMap<>())); + Map namespacesMetadataMap = Objects + .requireNonNull(itemsMetadataMap.computeIfAbsent(itemName, k -> new ConcurrentHashMap<>())); + + Set namespaceToBeRemoved = new HashSet<>(namespacesMetadataMap.keySet()); + + for (Map.Entry entry : metadata.entrySet()) { + String namespace = entry.getKey(); + YamlMetadataDTO mdDTO = entry.getValue(); + MetadataKey key = new MetadataKey(namespace, itemName); + Metadata md = new Metadata(key, mdDTO.value == null ? "" : mdDTO.value, mdDTO.config); + + namespaceToBeRemoved.remove(namespace); + Metadata oldMd = namespacesMetadataMap.get(namespace); + if (oldMd == null) { + namespacesMetadataMap.put(namespace, md); + logger.debug("notify added metadata {}", md.getUID()); + notifyListenersAboutAddedElement(md); + } else if (!md.getValue().equals(oldMd.getValue()) + || !YamlElementUtils.equalsConfig(md.getConfiguration(), oldMd.getConfiguration())) { + namespacesMetadataMap.put(namespace, md); + logger.debug("notify updated metadata {}", md.getUID()); + notifyListenersAboutUpdatedElement(oldMd, md); + } + } + + namespaceToBeRemoved.forEach(namespace -> { + Metadata md = namespacesMetadataMap.remove(namespace); + if (md != null) { + logger.debug("notify removed metadata {}", md.getUID()); + notifyListenersAboutRemovedElement(md); + } + }); + if (namespacesMetadataMap.isEmpty()) { + itemsMetadataMap.remove(itemName); + } + if (itemsMetadataMap.isEmpty()) { + metadataMap.remove(modelName); + } + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverter.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverter.java new file mode 100644 index 0000000000..925f8f6a6d --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverter.java @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2010-2025 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.items.fileconverter; + +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.config.core.ConfigDescription; +import org.openhab.core.config.core.ConfigDescriptionParameter; +import org.openhab.core.config.core.ConfigDescriptionRegistry; +import org.openhab.core.config.core.ConfigUtil; +import org.openhab.core.items.GroupFunction; +import org.openhab.core.items.GroupFunction.Equality; +import org.openhab.core.items.GroupItem; +import org.openhab.core.items.Item; +import org.openhab.core.items.ItemUtil; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.fileconverter.AbstractItemFileGenerator; +import org.openhab.core.items.fileconverter.ItemFileGenerator; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlModelRepository; +import org.openhab.core.model.yaml.internal.items.YamlGroupDTO; +import org.openhab.core.model.yaml.internal.items.YamlItemDTO; +import org.openhab.core.model.yaml.internal.items.YamlMetadataDTO; +import org.openhab.core.types.State; +import org.openhab.core.types.StateDescription; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * {@link YamlItemFileConverter} is the YAML file converter for {@link Item} object. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = ItemFileGenerator.class) +public class YamlItemFileConverter extends AbstractItemFileGenerator { + + private final YamlModelRepository modelRepository; + private final ConfigDescriptionRegistry configDescriptionRegistry; + + @Activate + public YamlItemFileConverter(final @Reference YamlModelRepository modelRepository, + final @Reference ConfigDescriptionRegistry configDescRegistry) { + super(); + this.modelRepository = modelRepository; + this.configDescriptionRegistry = configDescRegistry; + } + + @Override + public String getFileFormatGenerator() { + return "YAML"; + } + + @Override + public void generateFileFormat(OutputStream out, List items, Collection metadata, + boolean hideDefaultParameters) { + List elements = new ArrayList<>(); + items.forEach(item -> { + elements.add(buildItemDTO(item, getChannelLinks(metadata, item.getName()), + getMetadata(metadata, item.getName()), hideDefaultParameters)); + }); + modelRepository.generateSyntaxFromElements(out, elements); + } + + private YamlItemDTO buildItemDTO(Item item, List channelLinks, List metadata, + boolean hideDefaultParameters) { + YamlItemDTO dto = new YamlItemDTO(); + dto.name = item.getName(); + + String label = item.getLabel(); + boolean patternSet = false; + String defaultPattern = getDefaultStatePattern(item); + if (label != null && !label.isEmpty()) { + dto.label = item.getLabel(); + StateDescription stateDescr = item.getStateDescription(); + String statePattern = stateDescr == null ? null : stateDescr.getPattern(); + String patterToSet = statePattern != null && !statePattern.equals(defaultPattern) ? statePattern : null; + dto.format = patterToSet; + patternSet = patterToSet != null; + } + + dto.type = item.getType(); + String mainType = ItemUtil.getMainItemType(item.getType()); + String dimension = ItemUtil.getItemTypeExtension(item.getType()); + if (CoreItemFactory.NUMBER.equals(mainType) && dimension != null) { + dto.type = mainType; + dto.dimension = dimension; + } + if (item instanceof GroupItem groupItem) { + Item baseItem = groupItem.getBaseItem(); + if (baseItem != null) { + dto.group = new YamlGroupDTO(); + dto.group.type = baseItem.getType(); + mainType = ItemUtil.getMainItemType(baseItem.getType()); + dimension = ItemUtil.getItemTypeExtension(baseItem.getType()); + if (CoreItemFactory.NUMBER.equals(mainType) && dimension != null) { + dto.group.type = mainType; + dto.group.dimension = dimension; + } + GroupFunction function = groupItem.getFunction(); + if (function != null && !(function instanceof Equality)) { + dto.group.function = function.getClass().getSimpleName(); + List params = new ArrayList<>(); + State[] parameters = function.getParameters(); + for (int i = 0; i < parameters.length; i++) { + params.add(parameters[i].toString()); + } + dto.group.parameters = params.isEmpty() ? null : params; + } + } + } + + String category = item.getCategory(); + if (category != null && !category.isEmpty()) { + dto.icon = category; + } + + if (!item.getGroupNames().isEmpty()) { + dto.groups = new ArrayList<>(); + item.getGroupNames().forEach(group -> { + dto.groups.add(group); + }); + } + if (!item.getTags().isEmpty()) { + dto.tags = new LinkedHashSet<>(); + item.getTags().stream().sorted().collect(Collectors.toList()).forEach(tag -> { + dto.tags.add(tag); + }); + } + + if (channelLinks.size() == 1 && channelLinks.getFirst().getConfiguration().isEmpty()) { + dto.channel = channelLinks.getFirst().getValue(); + } else if (!channelLinks.isEmpty()) { + dto.channels = new LinkedHashMap<>(); + channelLinks.forEach(md -> { + Map configuration = new LinkedHashMap<>(); + getConfigurationParameters(md, hideDefaultParameters).forEach(param -> { + configuration.put(param.name(), param.value()); + }); + dto.channels.put(md.getValue(), configuration); + }); + } + + Map metadataDto = new LinkedHashMap<>(); + for (Metadata md : metadata) { + String namespace = md.getUID().getNamespace(); + if ("autoupdate".equals(namespace)) { + dto.autoupdate = Boolean.valueOf(md.getValue()); + } else if ("unit".equals(namespace)) { + dto.unit = md.getValue(); + } else { + YamlMetadataDTO mdDto = new YamlMetadataDTO(); + mdDto.value = md.getValue().isEmpty() ? null : md.getValue(); + Map configuration = new LinkedHashMap<>(); + String statePattern = null; + for (ConfigParameter param : getConfigurationParameters(md)) { + configuration.put(param.name(), param.value()); + if ("stateDescription".equals(namespace) && "pattern".equals(param.name())) { + statePattern = param.value().toString(); + } + } + // Ignore state description in case it contains only a state pattern and state pattern was injected + // in field format or is the default pattern + if (!(statePattern != null && configuration.size() == 1 + && (patternSet || statePattern.equals(defaultPattern)))) { + mdDto.config = configuration.isEmpty() ? null : configuration; + metadataDto.put(namespace, mdDto); + if (patternSet && statePattern != null) { + dto.format = null; + } + } + } + } + dto.metadata = metadataDto.isEmpty() ? null : metadataDto; + + return dto; + } + + /* + * Get the list of configuration parameters for a channel link. + * + * If a profile is set and a configuration description is found for this profile, the parameters are provided + * in the same order as in this configuration description, and any parameter having the default value is ignored. + * If no profile is set, the parameters are provided sorted by natural order of their names. + */ + private List getConfigurationParameters(Metadata metadata, boolean hideDefaultParameters) { + List parameters = new ArrayList<>(); + Set handledNames = new HashSet<>(); + Map configParameters = metadata.getConfiguration(); + Object profile = configParameters.get("profile"); + List configDescriptionParameter = List.of(); + if (profile instanceof String profileStr) { + parameters.add(new ConfigParameter("profile", profileStr)); + handledNames.add("profile"); + try { + ConfigDescription configDesc = configDescriptionRegistry + .getConfigDescription(new URI("profile:" + profileStr)); + if (configDesc != null) { + configDescriptionParameter = configDesc.getParameters(); + } + } catch (URISyntaxException e) { + // Ignored; in practice this will never be thrown + } + } + for (ConfigDescriptionParameter param : configDescriptionParameter) { + String paramName = param.getName(); + if (handledNames.contains(paramName)) { + continue; + } + Object value = configParameters.get(paramName); + Object defaultValue = ConfigUtil.getDefaultValueAsCorrectType(param); + if (value != null && (!hideDefaultParameters || !value.equals(defaultValue))) { + parameters.add(new ConfigParameter(paramName, value)); + } + handledNames.add(paramName); + } + for (String paramName : configParameters.keySet().stream().sorted().collect(Collectors.toList())) { + if (handledNames.contains(paramName)) { + continue; + } + Object value = configParameters.get(paramName); + if (value != null) { + parameters.add(new ConfigParameter(paramName, value)); + } + handledNames.add(paramName); + } + return parameters; + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/YamlElementUtils.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/YamlElementUtils.java index f8cf140d5d..105b49bbc0 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/YamlElementUtils.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/util/YamlElementUtils.java @@ -38,11 +38,8 @@ public class YamlElementUtils { public static boolean equalsConfig(@Nullable Map first, @Nullable Map second) { if (first != null && second != null) { - if (first.size() != second.size()) { - return false; - } else { - return first.entrySet().stream().allMatch(e -> equalsConfigValue(e.getValue(), second.get(e.getKey()))); - } + return first.size() != second.size() ? false + : first.entrySet().stream().allMatch(e -> equalsConfigValue(e.getValue(), second.get(e.getKey()))); } else { return first == null && second == null; } @@ -54,6 +51,22 @@ public class YamlElementUtils { : first.equals(second); } + public static boolean equalsListStrings(@Nullable List first, @Nullable List second) { + if (first != null && second != null) { + return Arrays.equals(first.toArray(), second.toArray()); + } else { + return first == null && second == null; + } + } + + public static boolean equalsSetStrings(@Nullable Set first, @Nullable Set second) { + if (first != null && second != null) { + return first.size() != second.size() ? false : first.containsAll(second); + } else { + return first == null && second == null; + } + } + public static @Nullable String getAdjustedItemType(@Nullable String type) { return type == null ? null : StringUtils.capitalize(type); } diff --git a/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/YamlGroupDTOTest.java b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/YamlGroupDTOTest.java new file mode 100644 index 0000000000..6b85e3575b --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/YamlGroupDTOTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2010-2025 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.items; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * The {@link YamlGroupDTOTest} contains tests for the {@link YamlGroupDTO} class. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class YamlGroupDTOTest { + + @Test + public void testGetBaseType() throws IOException { + YamlGroupDTO gr = new YamlGroupDTO(); + assertEquals(null, gr.getBaseType()); + gr.type = "Number"; + assertEquals("Number", gr.getBaseType()); + gr.type = "number"; + assertEquals("Number", gr.getBaseType()); + gr.dimension = "Dimensionless"; + assertEquals("Number:Dimensionless", gr.getBaseType()); + gr.dimension = "dimensionless"; + assertEquals("Number:Dimensionless", gr.getBaseType()); + } + + @Test + public void testGetFunction() throws IOException { + YamlGroupDTO gr = new YamlGroupDTO(); + assertEquals("EQUALITY", gr.getFunction()); + gr.function = "AND"; + assertEquals("AND", gr.getFunction()); + gr.function = "or"; + assertEquals("OR", gr.getFunction()); + gr.function = "Min"; + assertEquals("MIN", gr.getFunction()); + } + + @Test + public void testIsValid() throws IOException { + List err = new ArrayList<>(); + List warn = new ArrayList<>(); + + YamlGroupDTO gr = new YamlGroupDTO(); + assertTrue(gr.isValid(err, warn)); + + gr.type = "String"; + assertTrue(gr.isValid(err, warn)); + gr.type = "string"; + assertTrue(gr.isValid(err, warn)); + gr.type = "Other"; + assertFalse(gr.isValid(err, warn)); + gr.type = "Number"; + assertTrue(gr.isValid(err, warn)); + gr.dimension = "Dimensionless"; + assertTrue(gr.isValid(err, warn)); + gr.type = "number"; + gr.dimension = "dimensionless"; + assertTrue(gr.isValid(err, warn)); + gr.dimension = "Other"; + assertFalse(gr.isValid(err, warn)); + gr.type = "Color"; + gr.dimension = null; + assertEquals("Color", gr.getBaseType()); + + gr.function = "AND"; + assertTrue(gr.isValid(err, warn)); + gr.function = "or"; + assertTrue(gr.isValid(err, warn)); + gr.function = "Min"; + assertTrue(gr.isValid(err, warn)); + gr.function = "invalid"; + assertFalse(gr.isValid(err, warn)); + } + + @Test + public void testEquals() throws IOException { + YamlGroupDTO gr1 = new YamlGroupDTO(); + YamlGroupDTO gr2 = new YamlGroupDTO(); + + gr1.type = "String"; + gr2.type = "String"; + assertTrue(gr1.equals(gr2)); + gr1.type = "String"; + gr2.type = "Number"; + assertFalse(gr1.equals(gr2)); + gr1.type = "Number"; + gr2.type = "Number"; + assertTrue(gr1.equals(gr2)); + gr2.type = "number"; + assertTrue(gr1.equals(gr2)); + gr1.dimension = "Temperature"; + assertFalse(gr1.equals(gr2)); + gr2.dimension = "Humidity"; + assertFalse(gr1.equals(gr2)); + gr2.dimension = "Temperature"; + assertTrue(gr1.equals(gr2)); + gr2.dimension = "temperature"; + assertTrue(gr1.equals(gr2)); + + gr1.function = "or"; + gr2.function = null; + assertFalse(gr1.equals(gr2)); + gr1.function = null; + gr2.function = "or"; + assertFalse(gr1.equals(gr2)); + gr1.function = "or"; + gr2.function = "or"; + assertTrue(gr1.equals(gr2)); + gr2.function = "Or"; + assertTrue(gr1.equals(gr2)); + gr2.function = "OR"; + assertTrue(gr1.equals(gr2)); + + gr1.parameters = List.of("ON", "OFF"); + gr2.parameters = null; + assertFalse(gr1.equals(gr2)); + gr2.parameters = List.of("ON"); + assertFalse(gr1.equals(gr2)); + gr1.parameters = null; + gr2.parameters = List.of("ON", "OFF"); + assertFalse(gr1.equals(gr2)); + gr1.parameters = List.of("ON"); + assertFalse(gr1.equals(gr2)); + gr1.parameters = List.of("ON", "OFF"); + assertTrue(gr1.equals(gr2)); + } +} diff --git a/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/YamlItemDTOTest.java b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/YamlItemDTOTest.java new file mode 100644 index 0000000000..fad3430b1c --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/YamlItemDTOTest.java @@ -0,0 +1,400 @@ +/* + * Copyright (c) 2010-2025 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.items; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * The {@link YamlItemDTOTest} contains tests for the {@link YamlItemDTO} class. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class YamlItemDTOTest { + + @Test + public void testGetType() throws IOException { + YamlItemDTO item = new YamlItemDTO(); + assertEquals(null, item.getType()); + item.type = "Number"; + assertEquals("Number", item.getType()); + item.type = "number"; + assertEquals("Number", item.getType()); + item.dimension = "Dimensionless"; + assertEquals("Number:Dimensionless", item.getType()); + item.dimension = "dimensionless"; + assertEquals("Number:Dimensionless", item.getType()); + } + + @Test + public void testIsValid() throws IOException { + YamlItemDTO item = new YamlItemDTO(); + assertFalse(item.isValid(null, null)); + item.type = "Switch"; + item.name = "name"; + assertTrue(item.isValid(null, null)); + item.name = "$name"; + assertFalse(item.isValid(null, null)); + item.name = "my-name"; + assertTrue(item.isValid(null, null)); + + item.type = "Group"; + assertTrue(item.isValid(null, null)); + item.type = "GRoup"; + assertTrue(item.isValid(null, null)); + item.group = new YamlGroupDTO(); + item.group.type = "Switch"; + item.group.function = "OR"; + item.group.parameters = List.of("ON", "OFF"); + assertTrue(item.isValid(null, null)); + + item.type = "String"; + assertTrue(item.isValid(null, null)); + item.group = null; + item.type = "string"; + assertTrue(item.isValid(null, null)); + item.type = "Other"; + assertFalse(item.isValid(null, null)); + item.type = "Number"; + assertTrue(item.isValid(null, null)); + item.dimension = "Dimensionless"; + assertTrue(item.isValid(null, null)); + item.type = "number"; + item.dimension = "dimensionless"; + assertTrue(item.isValid(null, null)); + item.dimension = "Other"; + assertFalse(item.isValid(null, null)); + item.type = "Color"; + item.dimension = null; + assertTrue(item.isValid(null, null)); + + item.label = "My label"; + assertTrue(item.isValid(null, null)); + + item.icon = "xx:source:set:icon"; + assertFalse(item.isValid(null, null)); + item.icon = "source:set:icon"; + assertTrue(item.isValid(null, null)); + item.icon = "icon-source:$icon-set:my_icon"; + assertFalse(item.isValid(null, null)); + item.icon = "icon-source:icon-set:my_icon"; + assertTrue(item.isValid(null, null)); + + item.groups = List.of("group1", "group 2"); + assertFalse(item.isValid(null, null)); + item.groups = List.of("group1", "group2"); + assertTrue(item.isValid(null, null)); + + item.tags = Set.of("Tag1", "Tag 2"); + assertTrue(item.isValid(null, null)); + + item.channel = "binding:type:uid:channelid"; + assertTrue(item.isValid(null, null)); + item.channel = "binding:type:uid:group#channelid"; + assertTrue(item.isValid(null, null)); + item.channel = "binding:type:channelid"; + assertFalse(item.isValid(null, null)); + item.channel = "binding:$type:uid:group#channelid"; + assertFalse(item.isValid(null, null)); + item.channel = "binding:type:uid:group$channelid"; + assertFalse(item.isValid(null, null)); + } + + @Test + public void testEquals() throws IOException { + YamlItemDTO item1 = new YamlItemDTO(); + YamlItemDTO item2 = new YamlItemDTO(); + + item1.name = "item-name"; + item2.name = "item-name-2"; + assertFalse(item1.equals(item2)); + item2.name = "item-name"; + assertTrue(item1.equals(item2)); + + item1.type = "Number"; + item1.dimension = "Temperature"; + item2.type = "Number"; + item2.dimension = "Temperature"; + assertTrue(item1.equals(item2)); + + item1.label = "A label"; + item2.label = "A label"; + assertTrue(item1.equals(item2)); + + item1.icon = "oh:classic:temperature"; + item2.icon = "oh:classic:temperature"; + assertTrue(item1.equals(item2)); + + item1.groups = List.of("group1", "group2"); + item2.groups = List.of("group1", "group2"); + assertTrue(item1.equals(item2)); + + item1.tags = Set.of("Tag1", "Tag 2"); + item2.tags = Set.of("Tag1", "Tag 2"); + assertTrue(item1.equals(item2)); + item2.tags = Set.of("Tag 2", "Tag1"); + assertTrue(item1.equals(item2)); + + item1.group = new YamlGroupDTO(); + item1.group.type = "Switch"; + item1.group.function = "OR"; + item1.group.parameters = List.of("ON", "OFF"); + item2.group = new YamlGroupDTO(); + item2.group.type = "Switch"; + item2.group.function = "OR"; + item2.group.parameters = List.of("ON", "OFF"); + assertTrue(item1.equals(item2)); + } + + @Test + public void testEqualsWithLabel() throws IOException { + YamlItemDTO item1 = new YamlItemDTO(); + YamlItemDTO item2 = new YamlItemDTO(); + + item1.name = "item-name"; + item2.name = "item-name"; + item1.type = "String"; + item2.type = "String"; + + item1.label = null; + item2.label = null; + assertTrue(item1.equals(item2)); + item1.label = "A label"; + item2.label = null; + assertFalse(item1.equals(item2)); + item1.label = null; + item2.label = "A label"; + assertFalse(item1.equals(item2)); + item1.label = "A label"; + item2.label = "A different label"; + assertFalse(item1.equals(item2)); + item1.label = "A label"; + item2.label = "A label"; + assertTrue(item1.equals(item2)); + } + + @Test + public void testEqualsWithIcon() throws IOException { + YamlItemDTO item1 = new YamlItemDTO(); + YamlItemDTO item2 = new YamlItemDTO(); + + item1.name = "item-name"; + item2.name = "item-name"; + item1.type = "Number"; + item2.type = "Number"; + + item1.icon = null; + item2.icon = null; + assertTrue(item1.equals(item2)); + item1.icon = "humidity"; + item2.icon = null; + assertFalse(item1.equals(item2)); + item1.icon = null; + item2.icon = "humidity"; + assertFalse(item1.equals(item2)); + item1.icon = "humidity"; + item2.icon = "temperature"; + assertFalse(item1.equals(item2)); + item1.icon = "humidity"; + item2.icon = "humidity"; + assertTrue(item1.equals(item2)); + } + + @Test + public void testEqualsWithFormat() throws IOException { + YamlItemDTO item1 = new YamlItemDTO(); + YamlItemDTO item2 = new YamlItemDTO(); + + item1.name = "item-name"; + item2.name = "item-name"; + item1.type = "Number"; + item2.type = "Number"; + + item1.format = null; + item2.format = null; + assertTrue(item1.equals(item2)); + item1.format = "%.1f °C"; + item2.format = null; + assertFalse(item1.equals(item2)); + item1.format = null; + item2.format = "%.1f °C"; + assertFalse(item1.equals(item2)); + item1.format = "%.1f °C"; + item2.format = "%.0f °C"; + assertFalse(item1.equals(item2)); + item1.format = "%.1f °C"; + item2.format = "%.1f °C"; + assertTrue(item1.equals(item2)); + } + + @Test + public void testEqualsWithUnit() throws IOException { + YamlItemDTO item1 = new YamlItemDTO(); + YamlItemDTO item2 = new YamlItemDTO(); + + item1.name = "item-name"; + item2.name = "item-name"; + item1.type = "Number"; + item2.type = "Number"; + + item1.unit = null; + item2.unit = null; + assertTrue(item1.equals(item2)); + item1.unit = "°C"; + item2.unit = null; + assertFalse(item1.equals(item2)); + item1.unit = null; + item2.unit = "°C"; + assertFalse(item1.equals(item2)); + item1.unit = "°C"; + item2.unit = "°F"; + assertFalse(item1.equals(item2)); + item1.unit = "°C"; + item2.unit = "°C"; + assertTrue(item1.equals(item2)); + } + + @Test + public void testEqualsWithAutoupdate() throws IOException { + YamlItemDTO item1 = new YamlItemDTO(); + YamlItemDTO item2 = new YamlItemDTO(); + + item1.name = "item-name"; + item2.name = "item-name"; + item1.type = "Number"; + item2.type = "Number"; + + item1.autoupdate = null; + item2.autoupdate = null; + assertTrue(item1.equals(item2)); + item1.autoupdate = false; + item2.autoupdate = true; + assertFalse(item1.equals(item2)); + item1.autoupdate = true; + item2.autoupdate = false; + assertFalse(item1.equals(item2)); + item1.autoupdate = true; + item2.autoupdate = null; + assertFalse(item1.equals(item2)); + item1.autoupdate = null; + item2.autoupdate = true; + assertFalse(item1.equals(item2)); + item1.autoupdate = false; + item2.autoupdate = null; + assertFalse(item1.equals(item2)); + item1.autoupdate = null; + item2.autoupdate = false; + assertFalse(item1.equals(item2)); + item1.autoupdate = false; + item2.autoupdate = false; + assertTrue(item1.equals(item2)); + item1.autoupdate = true; + item2.autoupdate = true; + assertTrue(item1.equals(item2)); + } + + // @Test + // public void testEqualsWithConfigurations() throws IOException { + // YamlThingDTO th1 = new YamlThingDTO(); + // YamlThingDTO th2 = new YamlThingDTO(); + // + // th1.uid = "binding:type:id"; + // th2.uid = "binding:type:id"; + // + // th1.config = null; + // th2.config = null; + // assertTrue(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = null; + // assertFalse(th1.equals(th2)); + // th1.config = null; + // th2.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // assertFalse(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = Map.of("param1", "other value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // assertFalse(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = Map.of("param1", "value", "param2", 25, "param3", true, "param4", List.of("val 1", "val 2")); + // assertFalse(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = Map.of("param1", "value", "param2", 50, "param3", false, "param4", List.of("val 1", "val 2")); + // assertFalse(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "value 2")); + // assertFalse(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1")); + // assertFalse(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", 75); + // assertFalse(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = Map.of("param1", "value", "param2", 50, "param3", true); + // assertFalse(th1.equals(th2)); + // th1.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // th2.config = Map.of("param1", "value", "param2", 50, "param3", true, "param4", List.of("val 1", "val 2")); + // assertTrue(th1.equals(th2)); + // } + // + // @Test + // public void testEqualsWithChannels() throws IOException { + // YamlThingDTO th1 = new YamlThingDTO(); + // th1.uid = "binding:type:id"; + // YamlThingDTO th2 = new YamlThingDTO(); + // th2.uid = "binding:type:id"; + // + // YamlChannelDTO ch1 = new YamlChannelDTO(); + // ch1.type = "channel-type"; + // YamlChannelDTO ch2 = new YamlChannelDTO(); + // ch2.type = "channel-other-type"; + // YamlChannelDTO ch3 = new YamlChannelDTO(); + // ch3.type = "channel-type"; + // YamlChannelDTO ch4 = new YamlChannelDTO(); + // ch4.type = "channel-other-type"; + // + // th1.channels = Map.of("channel1", ch1); + // th2.channels = Map.of("channel1", ch3); + // assertTrue(th1.equals(th2)); + // + // th1.channels = Map.of("channel1", ch1, "channel2", ch2); + // th2.channels = Map.of("channel1", ch3, "channel2", ch4); + // assertTrue(th1.equals(th2)); + // + // th1.channels = Map.of("channel1", ch1); + // th2.channels = null; + // assertFalse(th1.equals(th2)); + // + // th1.channels = null; + // th2.channels = Map.of("channel1", ch3); + // assertFalse(th1.equals(th2)); + // + // th1.channels = Map.of("channel1", ch1, "channel2", ch2); + // th2.channels = Map.of("channel1", ch3); + // assertFalse(th1.equals(th2)); + // + // th1.channels = Map.of("channel1", ch1); + // th2.channels = Map.of("channel1", ch4); + // assertFalse(th1.equals(th2)); + // + // th1.channels = Map.of("channel", ch1); + // th2.channels = Map.of("channel1", ch3); + // assertFalse(th1.equals(th2)); + // } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/CoreItemFactory.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/CoreItemFactory.java index 0cd2af9a2b..0676fbee48 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/CoreItemFactory.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/CoreItemFactory.java @@ -41,7 +41,7 @@ import org.osgi.service.component.annotations.Reference; * @author Kai Kreuzer - Initial contribution * @author Alexander Kostadinov - Initial contribution */ -@Component(immediate = true) +@Component(immediate = true, service = { CoreItemFactory.class, ItemFactory.class }) @NonNullByDefault public class CoreItemFactory implements ItemFactory {