diff --git a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericItemProvider.java b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericItemProvider.java index 1aa48bc73b7..465394dd3ec 100644 --- a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericItemProvider.java +++ b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/GenericItemProvider.java @@ -36,6 +36,7 @@ import org.openhab.core.items.Item; import org.openhab.core.items.ItemFactory; import org.openhab.core.items.ItemProvider; +import org.openhab.core.items.ItemUtil; import org.openhab.core.items.dto.GroupFunctionDTO; import org.openhab.core.items.dto.ItemDTOMapper; import org.openhab.core.model.core.EventType; @@ -299,7 +300,7 @@ private void dispatchBindingsPerItemType(@Nullable BindingConfigReader reader, S if (model != null) { for (ModelItem modelItem : model.getItems()) { for (String itemType : itemTypes) { - if (itemType.equals(modelItem.getType())) { + if (itemType.equals(ItemUtil.getMainItemType(modelItem.getType()))) { Item item = createItemFromModelItem(modelItem); if (item != null) { internalDispatchBindings(reader, modelName, item, modelItem.getBindings()); diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/events/AbstractEvent.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/events/AbstractEvent.java index 99a1b079a07..ea906a431f1 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/events/AbstractEvent.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/events/AbstractEvent.java @@ -56,4 +56,50 @@ public String getPayload() { public @Nullable String getSource() { return source; } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((payload == null) ? 0 : payload.hashCode()); + result = prime * result + ((source == null) ? 0 : source.hashCode()); + result = prime * result + ((topic == null) ? 0 : topic.hashCode()); + return result; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + AbstractEvent other = (AbstractEvent) obj; + if (payload == null) { + if (other.payload != null) { + return false; + } + } else if (!payload.equals(other.payload)) { + return false; + } + if (source == null) { + if (other.source != null) { + return false; + } + } else if (!source.equals(other.source)) { + return false; + } + if (topic == null) { + if (other.topic != null) { + return false; + } + } else if (!topic.equals(other.topic)) { + return false; + } + return true; + } } diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/ExpireManager.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/ExpireManager.java new file mode 100644 index 00000000000..308e1678f0c --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/ExpireManager.java @@ -0,0 +1,385 @@ +/** + * Copyright (c) 2010-2020 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.internal.items; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.ThreadPoolManager; +import org.openhab.core.common.registry.RegistryChangeListener; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventFilter; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.events.EventSubscriber; +import org.openhab.core.items.Item; +import org.openhab.core.items.ItemNotFoundException; +import org.openhab.core.items.ItemRegistry; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.items.MetadataRegistry; +import org.openhab.core.items.events.ItemCommandEvent; +import org.openhab.core.items.events.ItemEventFactory; +import org.openhab.core.items.events.ItemStateEvent; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.Type; +import org.openhab.core.types.TypeParser; +import org.openhab.core.types.UnDefType; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Modified; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Component which takes care of sending item state expiry events. + * + * @author Kai Kreuzer - Initial contribution + * @author Michael Wyraz - Author of the 1.x expire binding, which this class is based on + */ +@NonNullByDefault +@Component(immediate = true, service = { ExpireManager.class, + EventSubscriber.class }, configurationPid = "org.openhab.expire", configurationPolicy = ConfigurationPolicy.OPTIONAL) +public class ExpireManager implements EventSubscriber, RegistryChangeListener { + + protected static final String EVENT_SOURCE = "org.openhab.core.expire"; + protected static final String METADATA_NAMESPACE = "expire"; + protected static final String PROPERTY_ENABLED = "enabled"; + + private static final Set SUBSCRIBED_EVENT_TYPES = Set.of(ItemStateEvent.TYPE, ItemCommandEvent.TYPE); + + private final Logger logger = LoggerFactory.getLogger(ExpireManager.class); + + private Map> itemExpireConfig = new ConcurrentHashMap<>(); + private Map itemExpireMap = new ConcurrentHashMap<>(); + + private ScheduledExecutorService threadPool = ThreadPoolManager + .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON); + + private final EventPublisher eventPublisher; + private final MetadataRegistry metadataRegistry; + private final ItemRegistry itemRegistry; + /* default */ final MetadataChangeListener metadataChangeListener = new MetadataChangeListener(); + + private boolean enabled = true; + + private @Nullable ScheduledFuture expireJob; + + @Activate + public ExpireManager(Map configuration, final @Reference EventPublisher eventPublisher, + final @Reference MetadataRegistry metadataRegistry, final @Reference ItemRegistry itemRegistry) { + this.eventPublisher = eventPublisher; + this.metadataRegistry = metadataRegistry; + this.itemRegistry = itemRegistry; + + modified(configuration); + } + + @Modified + protected void modified(Map configuration) { + Object valueEnabled = configuration.get(PROPERTY_ENABLED); + if (valueEnabled != null) { + enabled = Boolean.parseBoolean(valueEnabled.toString()); + } + if (enabled) { + ScheduledFuture localExpireJob = expireJob; + if (localExpireJob == null) { + expireJob = threadPool.scheduleWithFixedDelay(() -> { + if (!itemExpireMap.isEmpty()) { + for (String itemName : itemExpireConfig.keySet()) { + if (isReadyToExpire(itemName)) { + expire(itemName); + } + } + } + }, 1, 1, TimeUnit.SECONDS); + } + itemRegistry.addRegistryChangeListener(this); + metadataRegistry.addRegistryChangeListener(metadataChangeListener); + } else { + deactivate(); + } + } + + @Deactivate + protected void deactivate() { + ScheduledFuture localExpireJob = expireJob; + if (localExpireJob != null) { + localExpireJob.cancel(true); + expireJob = null; + } + itemRegistry.removeRegistryChangeListener(this); + metadataRegistry.removeRegistryChangeListener(metadataChangeListener); + itemExpireMap.clear(); + } + + private void processEvent(String itemName, Type type) { + logger.trace("Received '{}' for item {}", type, itemName); + ExpireConfig expireConfig = getExpireConfig(itemName); + if (expireConfig != null) { + Command expireCommand = expireConfig.expireCommand; + State expireState = expireConfig.expireState; + + if ((expireCommand != null && expireCommand.equals(type)) + || (expireState != null && expireState.equals(type))) { + // New event is expired command or state -> no further action needed + itemExpireMap.remove(itemName); // remove expire trigger until next update or command + logger.debug("Item {} received '{}'; stopping any future expiration.", itemName, type); + } else { + // New event is not the expired command or state, so add the trigger to the map + Duration duration = expireConfig.duration; + itemExpireMap.put(itemName, Instant.now().plus(duration)); + logger.debug("Item {} will expire (with '{}' {}) in {} ms", itemName, + expireCommand == null ? expireState : expireCommand, + expireCommand == null ? "state" : "command", duration); + } + } + } + + private @Nullable ExpireConfig getExpireConfig(String itemName) { + Optional itemConfig = itemExpireConfig.get(itemName); + if (itemConfig != null) { + return itemConfig.isPresent() ? itemConfig.get() : null; + } else { + Metadata metadata = metadataRegistry.get(new MetadataKey(METADATA_NAMESPACE, itemName)); + if (metadata != null) { + try { + Item item = itemRegistry.getItem(itemName); + try { + ExpireConfig cfg = new ExpireConfig(item, metadata.getValue()); + itemExpireConfig.put(itemName, Optional.of(cfg)); + return cfg; + } catch (IllegalArgumentException e) { + logger.warn("Expire config '{}' of item '{}' is invalid: {}", metadata.getValue(), itemName, + e.getMessage()); + } + } catch (ItemNotFoundException e) { + logger.debug("Item '{}' does not exist.", itemName); + } + } + // also fill the map when there is no config, so that we do not retry to find one + itemExpireConfig.put(itemName, Optional.empty()); + return null; + } + } + + private void postCommand(String itemName, Command command) { + eventPublisher.post(ItemEventFactory.createCommandEvent(itemName, command, EVENT_SOURCE)); + } + + private void postUpdate(String itemName, State state) { + eventPublisher.post(ItemEventFactory.createStateEvent(itemName, state, EVENT_SOURCE)); + } + + private boolean isReadyToExpire(String itemName) { + Instant nextExpiry = itemExpireMap.get(itemName); + return (nextExpiry != null) && Instant.now().isAfter(nextExpiry); + } + + private void expire(String itemName) { + itemExpireMap.remove(itemName); // disable expire trigger until next update or command + Optional expireConfig = itemExpireConfig.get(itemName); + + if (expireConfig != null && expireConfig.isPresent()) { + Command expireCommand = expireConfig.get().expireCommand; + State expireState = expireConfig.get().expireState; + + if (expireCommand != null) { + logger.debug("Item {} received no command or update for {} - posting command '{}'", itemName, + expireConfig.get().duration, expireCommand); + postCommand(itemName, expireCommand); + } else if (expireState != null) { + logger.debug("Item {} received no command or update for {} - posting state '{}'", itemName, + expireConfig.get().duration, expireState); + postUpdate(itemName, expireState); + } + } + } + + @Override + public Set getSubscribedEventTypes() { + return SUBSCRIBED_EVENT_TYPES; + } + + @Override + public @Nullable EventFilter getEventFilter() { + return null; + } + + @Override + public void receive(Event event) { + if (!enabled) { + return; + } + if (event instanceof ItemStateEvent) { + ItemStateEvent isEvent = (ItemStateEvent) event; + processEvent(isEvent.getItemName(), isEvent.getItemState()); + } else if (event instanceof ItemCommandEvent) { + ItemCommandEvent icEvent = (ItemCommandEvent) event; + processEvent(icEvent.getItemName(), icEvent.getItemCommand()); + } + } + + @Override + public void added(Item item) { + itemExpireConfig.remove(item.getName()); + } + + @Override + public void removed(Item item) { + itemExpireConfig.remove(item.getName()); + } + + @Override + public void updated(Item oldItem, Item item) { + itemExpireConfig.remove(item.getName()); + } + + class MetadataChangeListener implements RegistryChangeListener { + + @Override + public void added(Metadata element) { + itemExpireConfig.remove(element.getUID().getItemName()); + } + + @Override + public void removed(Metadata element) { + itemExpireConfig.remove(element.getUID().getItemName()); + } + + @Override + public void updated(Metadata oldElement, Metadata element) { + itemExpireConfig.remove(element.getUID().getItemName()); + } + } + + static class ExpireConfig { + + private static final StringType STRING_TYPE_NULL_HYPHEN = new StringType("'NULL'"); + private static final StringType STRING_TYPE_NULL = new StringType("NULL"); + private static final StringType STRING_TYPE_UNDEF_HYPHEN = new StringType("'UNDEF'"); + private static final StringType STRING_TYPE_UNDEF = new StringType("UNDEF"); + + protected static final String COMMAND_PREFIX = "command="; + protected static final String STATE_PREFIX = "state="; + + protected static final Pattern durationPattern = Pattern + .compile("(?:([0-9]+)H)?\\s*(?:([0-9]+)M)?\\s*(?:([0-9]+)S)?", Pattern.CASE_INSENSITIVE); + + @Nullable + final Command expireCommand; + @Nullable + final State expireState; + final String durationString; + final Duration duration; + + /** + * Construct an ExpireConfig from the config string. + * + * Valid syntax: + * + * {@code <duration>[,[state=|command=|]<stateOrCommand>]}
+ * if neither state= or command= is present, assume state + * + * @param item the item to which we are bound + * @param configString the string that the user specified in the metadate + * @throws IllegalArgumentException if it is ill-formatted + */ + public ExpireConfig(Item item, String configString) throws IllegalArgumentException { + int commaPos = configString.indexOf(','); + + durationString = (commaPos >= 0) ? configString.substring(0, commaPos).trim() : configString.trim(); + duration = parseDuration(durationString); + + String stateOrCommand = ((commaPos >= 0) && (configString.length() - 1) > commaPos) + ? configString.substring(commaPos + 1).trim() + : null; + + if ((stateOrCommand != null) && (stateOrCommand.length() > 0)) { + if (stateOrCommand.startsWith(COMMAND_PREFIX)) { + String commandString = stateOrCommand.substring(COMMAND_PREFIX.length()); + expireCommand = TypeParser.parseCommand(item.getAcceptedCommandTypes(), commandString); + expireState = null; + if (expireCommand == null) { + throw new IllegalArgumentException("The string '" + commandString + + "' does not represent a valid command for item " + item.getName()); + } + } else { + if (stateOrCommand.startsWith(STATE_PREFIX)) { + stateOrCommand = stateOrCommand.substring(STATE_PREFIX.length()); + } + String stateString = stateOrCommand; + State state = TypeParser.parseState(item.getAcceptedDataTypes(), stateString); + // do special handling to allow NULL and UNDEF as strings when being put in single quotes + if (STRING_TYPE_NULL_HYPHEN.equals(state)) { + expireState = STRING_TYPE_NULL; + } else if (STRING_TYPE_UNDEF_HYPHEN.equals(state)) { + expireState = STRING_TYPE_UNDEF; + } else { + expireState = state; + } + expireCommand = null; + if (expireState == null) { + throw new IllegalArgumentException("The string '" + stateString + + "' does not represent a valid state for item " + item.getName()); + } + } + } else { + // default is to post Undefined state + expireCommand = null; + expireState = UnDefType.UNDEF; + } + } + + private Duration parseDuration(String durationString) throws IllegalArgumentException { + Matcher m = durationPattern.matcher(durationString); + if (!m.matches() || (m.group(1) == null && m.group(2) == null && m.group(3) == null)) { + throw new IllegalArgumentException( + "Invalid duration: " + durationString + ". Expected something like: '1h 15m 30s'"); + } + + Duration duration = Duration.ZERO; + if (m.group(1) != null) { + duration = duration.plus(Duration.ofHours(Long.parseLong(m.group(1)))); + } + if (m.group(2) != null) { + duration = duration.plus(Duration.ofMinutes(Long.parseLong(m.group(2)))); + } + if (m.group(3) != null) { + duration = duration.plus(Duration.ofSeconds(Long.parseLong(m.group(3)))); + } + return duration; + } + + @Override + public String toString() { + return "duration='" + durationString + "', s=" + duration.toSeconds() + ", state='" + expireState + + "', command='" + expireCommand + "'"; + } + } +} diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/ExpireManagerTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/ExpireManagerTest.java new file mode 100644 index 00000000000..816063d3a4b --- /dev/null +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/ExpireManagerTest.java @@ -0,0 +1,223 @@ +/** + * Copyright (c) 2010-2020 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.internal.items; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; +import java.util.HashMap; + +import javax.measure.quantity.Temperature; + +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.internal.items.ExpireManager.ExpireConfig; +import org.openhab.core.items.Item; +import org.openhab.core.items.ItemNotFoundException; +import org.openhab.core.items.ItemRegistry; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.items.MetadataRegistry; +import org.openhab.core.items.events.ItemEventFactory; +import org.openhab.core.library.items.NumberItem; +import org.openhab.core.library.items.StringItem; +import org.openhab.core.library.items.SwitchItem; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.UnDefType; + +/** + * The {@link ExpireManagerTest} tests the {@link ExpireManager}. + * + * @author Kai Kreuzer - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +class ExpireManagerTest { + + private static final String ITEMNAME = "Test"; + private static final MetadataKey METADATA_KEY = new MetadataKey(ExpireManager.METADATA_NAMESPACE, ITEMNAME); + + private ExpireManager expireManager; + private @Mock EventPublisher eventPublisher; + private @Mock MetadataRegistry metadataRegistry; + private @Mock ItemRegistry itemRegistry; + + @BeforeEach + public void setup() { + expireManager = new ExpireManager(new HashMap(), eventPublisher, metadataRegistry, + itemRegistry); + } + + @Test + void testDefaultStateExpiry() throws InterruptedException { + when(metadataRegistry.get(METADATA_KEY)).thenReturn(new Metadata(METADATA_KEY, "1s", null)); + + Event event = ItemEventFactory.createCommandEvent(ITEMNAME, OnOffType.ON); + + expireManager.receive(event); + + verify(eventPublisher, never()).post(any()); + Thread.sleep(2500L); + verify(eventPublisher) + .post(eq(ItemEventFactory.createStateEvent(ITEMNAME, UnDefType.UNDEF, ExpireManager.EVENT_SOURCE))); + } + + @Test + void testStateExpiryWithCustomState() throws InterruptedException, ItemNotFoundException { + Item testItem = new SwitchItem(ITEMNAME); + when(itemRegistry.getItem(ITEMNAME)).thenReturn(testItem); + when(metadataRegistry.get(METADATA_KEY)).thenReturn(config("1s,state=OFF")); + + Event event = ItemEventFactory.createCommandEvent(ITEMNAME, OnOffType.ON); + + expireManager.receive(event); + + verify(eventPublisher, never()).post(any()); + Thread.sleep(2500L); + verify(eventPublisher) + .post(eq(ItemEventFactory.createStateEvent(ITEMNAME, OnOffType.OFF, ExpireManager.EVENT_SOURCE))); + } + + @Test + void testStateExpiryWithCustomCommand() throws InterruptedException, ItemNotFoundException { + Item testItem = new SwitchItem(ITEMNAME); + when(itemRegistry.getItem(ITEMNAME)).thenReturn(testItem); + when(metadataRegistry.get(METADATA_KEY)).thenReturn(config("1s,command=ON")); + + Event event = ItemEventFactory.createCommandEvent(ITEMNAME, OnOffType.OFF); + + expireManager.receive(event); + + verify(eventPublisher, never()).post(any()); + Thread.sleep(2500L); + verify(eventPublisher) + .post(eq(ItemEventFactory.createCommandEvent(ITEMNAME, OnOffType.ON, ExpireManager.EVENT_SOURCE))); + } + + @Test + void testCancelExpiry() throws InterruptedException, ItemNotFoundException { + Item testItem = new SwitchItem(ITEMNAME); + when(itemRegistry.getItem(ITEMNAME)).thenReturn(testItem); + when(metadataRegistry.get(METADATA_KEY)).thenReturn(config("1s,ON")); + + Event event = ItemEventFactory.createCommandEvent(ITEMNAME, OnOffType.OFF); + expireManager.receive(event); + Thread.sleep(500L); + event = ItemEventFactory.createCommandEvent(ITEMNAME, OnOffType.ON); + expireManager.receive(event); + Thread.sleep(2000L); + verify(eventPublisher, never()).post(any()); + } + + @Test + void testMetadataChange() throws InterruptedException, ItemNotFoundException { + Metadata md = new Metadata(METADATA_KEY, "1s", null); + when(metadataRegistry.get(METADATA_KEY)).thenReturn(md); + + Event event = ItemEventFactory.createCommandEvent(ITEMNAME, OnOffType.ON); + expireManager.receive(event); + + verify(eventPublisher, never()).post(any()); + Thread.sleep(2500L); + verify(eventPublisher) + .post(eq(ItemEventFactory.createStateEvent(ITEMNAME, UnDefType.UNDEF, ExpireManager.EVENT_SOURCE))); + + when(metadataRegistry.get(METADATA_KEY)).thenReturn(null); + expireManager.metadataChangeListener.removed(md); + reset(eventPublisher); + + event = ItemEventFactory.createCommandEvent(ITEMNAME, OnOffType.ON); + expireManager.receive(event); + verify(eventPublisher, never()).post(any()); + Thread.sleep(2500L); + verify(eventPublisher, never()).post(any()); + } + + @Test + void testExpireConfig() { + Item testItem = new SwitchItem(ITEMNAME); + ExpireConfig cfg = new ExpireManager.ExpireConfig(testItem, "1s"); + assertEquals(Duration.ofSeconds(1), cfg.duration); + assertEquals(UnDefType.UNDEF, cfg.expireState); + assertEquals(null, cfg.expireCommand); + + cfg = new ExpireManager.ExpireConfig(testItem, "5h 3m 2s"); + assertEquals(Duration.ofHours(5).plusMinutes(3).plusSeconds(2), cfg.duration); + assertEquals(UnDefType.UNDEF, cfg.expireState); + assertEquals(null, cfg.expireCommand); + + cfg = new ExpireManager.ExpireConfig(testItem, "1h,OFF"); + assertEquals(Duration.ofHours(1), cfg.duration); + assertEquals(OnOffType.OFF, cfg.expireState); + assertEquals(null, cfg.expireCommand); + + cfg = new ExpireManager.ExpireConfig(testItem, "1h,state=OFF"); + assertEquals(Duration.ofHours(1), cfg.duration); + assertEquals(OnOffType.OFF, cfg.expireState); + assertEquals(null, cfg.expireCommand); + + cfg = new ExpireManager.ExpireConfig(testItem, "1h,command=OFF"); + assertEquals(Duration.ofHours(1), cfg.duration); + assertEquals(null, cfg.expireState); + assertEquals(OnOffType.OFF, cfg.expireCommand); + + try { + cfg = new ExpireManager.ExpireConfig(testItem, "1h,command=OPEN"); + fail(); + } catch (IllegalArgumentException e) { + // expected as command is invalid + } + + try { + cfg = new ExpireManager.ExpireConfig(testItem, "1h,OPEN"); + fail(); + } catch (IllegalArgumentException e) { + // expected as state is invalid + } + + testItem = new NumberItem("Number:Temperature", ITEMNAME); + cfg = new ExpireManager.ExpireConfig(testItem, "1h,15 °C"); + assertEquals(Duration.ofHours(1), cfg.duration); + assertEquals(new QuantityType("15 °C"), cfg.expireState); + assertEquals(null, cfg.expireCommand); + + testItem = new StringItem(ITEMNAME); + cfg = new ExpireManager.ExpireConfig(testItem, "1h,NULL"); + assertEquals(Duration.ofHours(1), cfg.duration); + assertEquals(UnDefType.NULL, cfg.expireState); + assertEquals(null, cfg.expireCommand); + + cfg = new ExpireManager.ExpireConfig(testItem, "1h,'NULL'"); + assertEquals(Duration.ofHours(1), cfg.duration); + assertEquals(new StringType("NULL"), cfg.expireState); + assertEquals(null, cfg.expireCommand); + + cfg = new ExpireManager.ExpireConfig(testItem, "1h,'UNDEF'"); + assertEquals(Duration.ofHours(1), cfg.duration); + assertEquals(new StringType("UNDEF"), cfg.expireState); + assertEquals(null, cfg.expireCommand); + } + + private Metadata config(String cfg) { + return new Metadata(METADATA_KEY, cfg, null); + } +}