diff --git a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java index 47e06f3c5..82d559af1 100644 --- a/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java +++ b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java @@ -49,6 +49,7 @@ public class YamlItemDTO implements YamlElement, Cloneable { public String icon; public String format; public String unit; + public String expire; public Boolean autoupdate; public List<@NonNull String> groups; public Set<@NonNull String> tags; @@ -195,6 +196,12 @@ public class YamlItemDTO implements YamlElement, Cloneable { "item \"%s\": \"format\" field is redundant with pattern in \"stateDescription\" metadata; \"%s\" will be considered" .formatted(name, pattern)); } + md = metadata.get("expire"); + if (md != null && expire != null) { + addToList(warnings, + "item \"%s\": \"expire\" field is redundant with \"expire\" metadata; value \"%s\" will be considered" + .formatted(name, md.getValue())); + } } return ok; } @@ -266,8 +273,8 @@ public class YamlItemDTO implements YamlElement, Cloneable { @Override public int hashCode() { - return Objects.hash(name, getType(), group, label, icon, format, unit, autoupdate, getGroups(), getTags(), - channel); + return Objects.hash(name, getType(), group, label, icon, format, unit, expire, autoupdate, getGroups(), + getTags(), channel); } @Override @@ -281,10 +288,10 @@ public class YamlItemDTO implements YamlElement, Cloneable { 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) - && Objects.equals(getGroups(), other.getGroups()) && Objects.equals(getTags(), other.getTags()) - && Objects.equals(channel, other.channel) && equalsChannels(channels, other.channels) - && equalsMetadata(metadata, other.metadata); + && Objects.equals(unit, other.unit) && Objects.equals(expire, other.expire) + && Objects.equals(autoupdate, other.autoupdate) && Objects.equals(getGroups(), other.getGroups()) + && Objects.equals(getTags(), other.getTags()) && 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, 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 index d7a4d4bc7..cde8cbea8 100644 --- 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 @@ -264,6 +264,7 @@ public class YamlItemProvider extends AbstractProvider implements ItemProv boolean hasAutoUpdateMetadata = false; boolean hasUnitMetadata = false; boolean hasStateDescriptionMetadata = false; + boolean hasExpireMetadata = false; if (itemDTO.metadata != null) { for (Map.Entry entry : itemDTO.metadata.entrySet()) { if ("autoupdate".equals(entry.getKey())) { @@ -272,6 +273,8 @@ public class YamlItemProvider extends AbstractProvider implements ItemProv hasUnitMetadata = true; } else if ("stateDescription".equals(entry.getKey())) { hasStateDescriptionMetadata = true; + } else if ("expire".equals(entry.getKey())) { + hasExpireMetadata = true; } Map config = entry.getValue().config; if (itemDTO.format != null && "stateDescription".equals(entry.getKey()) @@ -305,6 +308,11 @@ public class YamlItemProvider extends AbstractProvider implements ItemProv mdDTO.config = Map.of("pattern", itemDTO.format); metadata.put("stateDescription", mdDTO); } + if (!hasExpireMetadata && itemDTO.expire != null) { + YamlMetadataDTO mdDTO = new YamlMetadataDTO(); + mdDTO.value = itemDTO.expire; + metadata.put("expire", 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/fileconverter/YamlItemFileConverter.java b/bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverter.java index 56deb0411..6a6e786ee 100644 --- 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 @@ -184,13 +184,28 @@ public class YamlItemFileConverter extends AbstractItemFileGenerator implements Map metadataDto = new LinkedHashMap<>(); for (Metadata md : metadata) { String namespace = md.getUID().getNamespace(); + String value = md.getValue(); if ("autoupdate".equals(namespace)) { - dto.autoupdate = Boolean.valueOf(md.getValue()); + // When autoupdate value is an empty string, treat it as not set since dto.autoupdate only accepts + // true/false + if (value != null && !value.isEmpty()) { + dto.autoupdate = Boolean.valueOf(value); + } } else if ("unit".equals(namespace)) { - dto.unit = md.getValue(); + dto.unit = value; // When unit value is empty string, keep it as empty string + } else if ("expire".equals(namespace)) { + Map configuration = md.getConfiguration(); + if (configuration.isEmpty()) { + dto.expire = value; // When expire value is empty string, keep it as empty string + } else { + YamlMetadataDTO mdDto = new YamlMetadataDTO(); + mdDto.value = value.isEmpty() ? null : value; + mdDto.config = configuration; + metadataDto.put(namespace, mdDto); + } } else { YamlMetadataDTO mdDto = new YamlMetadataDTO(); - mdDto.value = md.getValue().isEmpty() ? null : md.getValue(); + mdDto.value = value.isEmpty() ? null : value; Map configuration = new LinkedHashMap<>(); String statePattern = null; for (ConfigParameter param : getConfigurationParameters(md)) { 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 index 7fd095489..cf90fde6b 100644 --- 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 @@ -15,6 +15,7 @@ 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 java.util.Map; import java.util.Set; @@ -376,6 +377,34 @@ public class YamlItemDTOTest { assertEquals(item1.hashCode(), item2.hashCode()); } + @Test + public void testEqualsWithExpire() throws IOException { + YamlItemDTO item1 = new YamlItemDTO(); + YamlItemDTO item2 = new YamlItemDTO(); + + item1.name = "item_name"; + item2.name = "item_name"; + item1.type = "Switch"; + item2.type = "Switch"; + + item1.expire = null; + item2.expire = null; + assertTrue(item1.equals(item2)); + assertEquals(item1.hashCode(), item2.hashCode()); + item1.expire = "5m"; + item2.expire = null; + assertFalse(item1.equals(item2)); + item1.expire = null; + item2.expire = "5m"; + assertFalse(item1.equals(item2)); + item1.expire = "5m"; + item2.expire = "1h"; + assertFalse(item1.equals(item2)); + item2.expire = "5m"; + assertTrue(item1.equals(item2)); + assertEquals(item1.hashCode(), item2.hashCode()); + } + @Test public void testEqualsWithGroups() throws IOException { YamlItemDTO item1 = new YamlItemDTO(); @@ -551,4 +580,26 @@ public class YamlItemDTOTest { item2.metadata = Map.of("namespace", md3, "namespace2", md2); assertFalse(item1.equals(item2)); } + + @Test + public void testExpireMetadataWarning() throws IOException { + YamlItemDTO item = new YamlItemDTO(); + item.name = "item_name"; + item.type = "String"; + item.expire = "10m"; + + YamlMetadataDTO md = new YamlMetadataDTO(); + md.value = "5m"; + item.metadata = Map.of("expire", md); + + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + + assertTrue(item.isValid(errors, warnings)); + assertTrue(errors.isEmpty()); + assertEquals(1, warnings.size()); + assertEquals( + "item \"item_name\": \"expire\" field is redundant with \"expire\" metadata; value \"5m\" will be considered", + warnings.get(0)); + } } diff --git a/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverterTest.java b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverterTest.java new file mode 100644 index 000000000..4fc86b1a8 --- /dev/null +++ b/bundles/org.openhab.core.model.yaml/src/test/java/org/openhab/core/model/yaml/internal/items/fileconverter/YamlItemFileConverterTest.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.items.fileconverter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.openhab.core.config.core.ConfigDescriptionRegistry; +import org.openhab.core.items.Item; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.model.yaml.YamlElement; +import org.openhab.core.model.yaml.YamlModelRepository; +import org.openhab.core.model.yaml.internal.items.YamlChannelLinkProvider; +import org.openhab.core.model.yaml.internal.items.YamlItemDTO; +import org.openhab.core.model.yaml.internal.items.YamlItemProvider; +import org.openhab.core.model.yaml.internal.items.YamlMetadataProvider; + +@NonNullByDefault +public class YamlItemFileConverterTest { + + @Test + public void testExpireMetadataConvertedToShortForm() { + Metadata expireMetadata = new Metadata(new MetadataKey("expire", "item_name"), "10m", Map.of()); + YamlItemDTO dto = convertWithMetadata(expireMetadata, "String"); + assertEquals("10m", dto.expire); + assertNull(dto.metadata); + } + + @Test + public void testExpireMetadataEmptyStringStaysInShortForm() { + Metadata expireMetadata = new Metadata(new MetadataKey("expire", "item_name"), "", Map.of()); + YamlItemDTO dto = convertWithMetadata(expireMetadata, "String"); + assertEquals("", dto.expire); + assertNull(dto.metadata); + } + + @Test + public void testExpireMetadataWithConfigStaysMetadata() { + Metadata expireMetadata = new Metadata(new MetadataKey("expire", "item_name"), "10m", Map.of("command", "OFF")); + YamlItemDTO dto = convertWithMetadata(expireMetadata, "String"); + assertNull(dto.expire); + assertNotNull(dto.metadata); + assertTrue(dto.metadata.containsKey("expire")); + assertEquals("10m", dto.metadata.get("expire").getValue()); + assertEquals("OFF", dto.metadata.get("expire").config.get("command")); + } + + @Test + public void testAutoupdateMetadataSetsField() { + Metadata autoupdate = new Metadata(new MetadataKey("autoupdate", "item_name"), "true", Map.of()); + YamlItemDTO dto = convertWithMetadata(autoupdate, "String"); + assertEquals(Boolean.TRUE, dto.autoupdate); + assertNull(dto.metadata); + } + + @Test + public void testAutoupdateMetadataEmptyStringIsTreatedAsNotSet() { + Metadata autoupdate = new Metadata(new MetadataKey("autoupdate", "item_name"), "", Map.of()); + YamlItemDTO dto = convertWithMetadata(autoupdate, "String"); + assertNull(dto.autoupdate); + assertNull(dto.metadata); + } + + @Test + public void testUnitMetadataSetsField() { + Metadata unit = new Metadata(new MetadataKey("unit", "item_name"), "kWh", Map.of()); + YamlItemDTO dto = convertWithMetadata(unit, "Number"); + assertEquals("kWh", dto.unit); + assertNull(dto.metadata); + } + + @Test + public void testUnitMetadataEmptyStringStaysInShortForm() { + Metadata unit = new Metadata(new MetadataKey("unit", "item_name"), "", Map.of()); + YamlItemDTO dto = convertWithMetadata(unit, "Number"); + assertEquals("", dto.unit); + assertNull(dto.metadata); + } + + private YamlItemDTO convertWithMetadata(Metadata metadata, String itemType) { + CapturingYamlModelRepository repository = new CapturingYamlModelRepository(); + YamlItemFileConverter converter = new YamlItemFileConverter(repository, mock(YamlItemProvider.class), + mock(YamlMetadataProvider.class), mock(YamlChannelLinkProvider.class), + mock(ConfigDescriptionRegistry.class)); + + Item item = mock(Item.class); + when(item.getName()).thenReturn(metadata.getUID().getItemName()); + when(item.getLabel()).thenReturn(null); + when(item.getType()).thenReturn(itemType); + when(item.getCategory()).thenReturn(null); + when(item.getGroupNames()).thenReturn(List.of()); + when(item.getTags()).thenReturn(Set.of()); + + converter.setItemsToBeGenerated("id", List.of(item), List.of(metadata), Map.of(), false); + + List elements = repository.getElements(); + assertEquals(1, elements.size()); + assertInstanceOf(YamlItemDTO.class, elements.get(0)); + return (YamlItemDTO) elements.get(0); + } + + private static class CapturingYamlModelRepository implements YamlModelRepository { + + private List elements = new ArrayList<>(); + + @Override + public void addElementToModel(String modelName, YamlElement element) { + // no-op + } + + @Override + public void removeElementFromModel(String modelName, YamlElement element) { + // no-op + } + + @Override + public void updateElementInModel(String modelName, YamlElement element) { + // no-op + } + + @Override + public void addElementsToBeGenerated(String id, List elements) { + this.elements = elements; + } + + @Override + public void generateFileFormat(String id, OutputStream out) { + // no-op + } + + @Override + public @Nullable String createIsolatedModel(InputStream inputStream, List errors, + List warnings) { + return null; + } + + @Override + public void removeIsolatedModel(String modelName) { + // no-op + } + + public List getElements() { + return elements; + } + } +}