Make `expire` support days, ISO8601 Duration and fully configurable through metadata configuration map (#4724)

* Make `expire` fully configurable through metadata configuration map
* raise an error when setting is specified in both value and config map
* support days and ISO8601
* unknown config raises an error
* throws an exception on negative duration

Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
pull/4730/head
jimtng 2025-04-19 11:52:32 +10:00 committed by GitHub
parent 9e7aa7bf32
commit e4cc912f04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 194 additions and 35 deletions

View File

@ -14,6 +14,7 @@ package org.openhab.core.internal.items;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@ -289,19 +290,24 @@ public class ExpireManager implements EventSubscriber, RegistryChangeListener<It
}
static class ExpireConfig {
static final String CONFIG_DURATION = "duration";
static final String CONFIG_COMMAND = "command";
static final String CONFIG_STATE = "state";
static final String CONFIG_IGNORE_STATE_UPDATES = "ignoreStateUpdates";
static final String CONFIG_IGNORE_COMMANDS = "ignoreCommands";
static final Set<String> CONFIG_KEYS = Set.of(CONFIG_DURATION, CONFIG_COMMAND, CONFIG_STATE,
CONFIG_IGNORE_STATE_UPDATES, CONFIG_IGNORE_COMMANDS);
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 String COMMAND_PREFIX = CONFIG_COMMAND + "=";
protected static final String STATE_PREFIX = CONFIG_STATE + "=";
protected static final Pattern DURATION_PATTERN = Pattern
.compile("(?:([0-9]+)H)?\\s*(?:([0-9]+)M)?\\s*(?:([0-9]+)S)?", Pattern.CASE_INSENSITIVE);
protected static final Pattern DURATION_PATTERN = Pattern.compile(
"(?:([0-9]+)D)?\\s*(?:([0-9]+)H)?\\s*(?:([0-9]+)M)?\\s*(?:([0-9]+)S)?", Pattern.CASE_INSENSITIVE);
final @Nullable Command expireCommand;
final @Nullable State expireState;
@ -315,19 +321,48 @@ public class ExpireManager implements EventSubscriber, RegistryChangeListener<It
*
* Valid syntax:
*
* {@code &lt;duration&gt;[,(state=|command=|)&lt;stateOrCommand&gt;][,ignoreStateUpdates][,ignoreCommands]}<br>
* {@code &lt;duration&gt;[,(state=|command=|)&lt;stateOrCommand&gt;]}<br>
* if neither state= or command= is present, assume state
*
* {@code duration} is a string of the form "1d1h15m30s" or "1d" or "1h" or "15m" or "30s",
* or an ISO-8601 duration string (e.g. "PT1H15M30S").
*
* {@code configuration} is a map of configuration keys and values:
* - {@code duration}: the duration string
* - {@code command}: the {@link Command} to send when the item expires
* - {@code state}: the {@link State} to send when the item expires
* - {@code ignoreStateUpdates}: if true, ignore state updates
* - {@code ignoreCommands}: if true, ignore commands
*
* - When neither command nor state is specified, the default is to post an {@link UNDEF} 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
* @param configString the string that the user specified in the metadata
* @param configuration the configuration map
* @throws IllegalArgumentException if it is ill-formatted, or the configuration contains an unknown key,
* or any setting is specified more than once
*/
public ExpireConfig(Item item, String configString, Map<String, Object> configuration)
throws IllegalArgumentException {
int commaPos = configString.indexOf(',');
String commandString = null;
String stateString = null;
durationString = (commaPos >= 0) ? configString.substring(0, commaPos).trim() : configString.trim();
String durationStr = (commaPos >= 0) ? configString.substring(0, commaPos).trim() : configString.trim();
if (configuration.containsKey(CONFIG_DURATION)) {
if (!durationStr.isEmpty()) {
throw new IllegalArgumentException("Expire duration for item " + item.getName()
+ " is specified in both the value string and the configuration");
}
durationStr = configuration.get(CONFIG_DURATION).toString();
}
durationString = durationStr;
duration = parseDuration(durationString);
if (duration.isNegative()) {
throw new IllegalArgumentException(
"Expire duration for item " + item.getName() + " must be a positive value");
}
String stateOrCommand = ((commaPos >= 0) && (configString.length() - 1) > commaPos)
? configString.substring(commaPos + 1).trim()
@ -336,40 +371,70 @@ public class ExpireManager implements EventSubscriber, RegistryChangeListener<It
ignoreStateUpdates = getBooleanConfigValue(configuration, CONFIG_IGNORE_STATE_UPDATES);
ignoreCommands = getBooleanConfigValue(configuration, CONFIG_IGNORE_COMMANDS);
if (configuration.containsKey(CONFIG_COMMAND)) {
commandString = configuration.get(CONFIG_COMMAND).toString();
}
if (configuration.containsKey(CONFIG_STATE)) {
if (commandString != null) {
throw new IllegalArgumentException(
"Expire configuration for item " + item.getName() + " contains both command and state");
}
stateString = configuration.get(CONFIG_STATE).toString();
}
if ((stateOrCommand != null) && (!stateOrCommand.isEmpty())) {
if (commandString != null || stateString != null) {
throw new IllegalArgumentException("Expire state/command for item " + item.getName()
+ " is specified in both the value string and the configuration");
}
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());
}
commandString = stateOrCommand.substring(COMMAND_PREFIX.length());
} 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());
}
stateString = stateOrCommand;
}
}
if (commandString != null) {
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 (stateString != null) {
// default is to post state
expireCommand = null;
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;
}
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;
}
if (!CONFIG_KEYS.containsAll(configuration.keySet())) {
Set<String> unknownKeys = new HashSet<String>(configuration.keySet());
unknownKeys.removeAll(CONFIG_KEYS);
throw new IllegalArgumentException(
"Expire configuration for item " + item.getName() + " contains unknown keys: " + unknownKeys);
}
}
/**
@ -394,21 +459,31 @@ public class ExpireManager implements EventSubscriber, RegistryChangeListener<It
}
private Duration parseDuration(String durationString) throws IllegalArgumentException {
try {
return Duration.parse(durationString);
} catch (Exception e) {
// ignore
}
Matcher m = DURATION_PATTERN.matcher(durationString);
if (!m.matches() || (m.group(1) == null && m.group(2) == null && m.group(3) == null)) {
if (!m.matches()
|| (m.group(1) == null && m.group(2) == null && m.group(3) == null && m.group(4) == null)) {
throw new IllegalArgumentException(
"Invalid duration: " + durationString + ". Expected something like: '1h 15m 30s'");
"Invalid duration: " + durationString + ". Expected something like: '1d 1h 15m 30s'");
}
Duration duration = Duration.ZERO;
if (m.group(1) != null) {
duration = duration.plus(Duration.ofHours(Long.parseLong(m.group(1))));
duration = duration.plus(Duration.ofDays(Long.parseLong(m.group(1))));
}
if (m.group(2) != null) {
duration = duration.plus(Duration.ofMinutes(Long.parseLong(m.group(2))));
duration = duration.plus(Duration.ofHours(Long.parseLong(m.group(2))));
}
if (m.group(3) != null) {
duration = duration.plus(Duration.ofSeconds(Long.parseLong(m.group(3))));
duration = duration.plus(Duration.ofMinutes(Long.parseLong(m.group(3))));
}
if (m.group(4) != null) {
duration = duration.plus(Duration.ofSeconds(Long.parseLong(m.group(4))));
}
return duration;
}

View File

@ -277,6 +277,41 @@ class ExpireManagerTest {
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "7d 5h 3m 2s", Map.of());
assertEquals(Duration.ofDays(7).plusHours(5).plusMinutes(3).plusSeconds(2), cfg.duration);
assertEquals(UnDefType.UNDEF, cfg.expireState);
assertNull(cfg.expireCommand);
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "PT5H3M2S", Map.of());
assertEquals(Duration.ofHours(5).plusMinutes(3).plusSeconds(2), cfg.duration);
assertEquals(UnDefType.UNDEF, cfg.expireState);
assertNull(cfg.expireCommand);
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "P7DT5H3M2S", Map.of());
assertEquals(Duration.ofDays(7).plusHours(5).plusMinutes(3).plusSeconds(2), cfg.duration);
assertEquals(UnDefType.UNDEF, cfg.expireState);
assertNull(cfg.expireCommand);
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "", Map.of("duration", "5h 3m 2s"));
assertEquals(Duration.ofHours(5).plusMinutes(3).plusSeconds(2), cfg.duration);
assertEquals(UnDefType.UNDEF, cfg.expireState);
assertNull(cfg.expireCommand);
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "", Map.of("duration", "PT5H3M2S"));
assertEquals(Duration.ofHours(5).plusMinutes(3).plusSeconds(2), cfg.duration);
assertEquals(UnDefType.UNDEF, cfg.expireState);
assertNull(cfg.expireCommand);
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "1h,OFF", Map.of());
assertEquals(Duration.ofHours(1), cfg.duration);
assertEquals(OnOffType.OFF, cfg.expireState);
@ -291,6 +326,13 @@ class ExpireManagerTest {
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "1h", Map.of("state", "OFF"));
assertEquals(Duration.ofHours(1), cfg.duration);
assertEquals(OnOffType.OFF, cfg.expireState);
assertNull(cfg.expireCommand);
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "1h,command=OFF", Map.of());
assertEquals(Duration.ofHours(1), cfg.duration);
assertNull(cfg.expireState);
@ -298,6 +340,13 @@ class ExpireManagerTest {
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "1h", Map.of("command", "OFF"));
assertEquals(Duration.ofHours(1), cfg.duration);
assertNull(cfg.expireState);
assertEquals(OnOffType.OFF, cfg.expireCommand);
assertFalse(cfg.ignoreStateUpdates);
assertFalse(cfg.ignoreCommands);
cfg = new ExpireManager.ExpireConfig(testItem, "1h,command=OFF",
Map.of(ExpireConfig.CONFIG_IGNORE_STATE_UPDATES, true));
assertEquals(Duration.ofHours(1), cfg.duration);
@ -368,6 +417,41 @@ class ExpireManagerTest {
assertNull(cfg.expireCommand);
}
@Test
void testValueVsConfigMix() {
Item testItem = new SwitchItem(ITEMNAME);
assertThrows(IllegalArgumentException.class,
() -> new ExpireManager.ExpireConfig(testItem, "1h", Map.of("duration", "1h")));
assertThrows(IllegalArgumentException.class,
() -> new ExpireManager.ExpireConfig(testItem, "1h,state=ON", Map.of("state", "ON")));
assertThrows(IllegalArgumentException.class,
() -> new ExpireManager.ExpireConfig(testItem, "1h,state=ON", Map.of("command", "ON")));
assertThrows(IllegalArgumentException.class,
() -> new ExpireManager.ExpireConfig(testItem, "1h,command=ON", Map.of("command", "ON")));
assertThrows(IllegalArgumentException.class,
() -> new ExpireManager.ExpireConfig(testItem, "1h,command=ON", Map.of("state", "ON")));
assertThrows(IllegalArgumentException.class, //
() -> new ExpireManager.ExpireConfig(testItem, "1h,command=ON",
Map.of("command", "ON", "state", "ON")));
assertThrows(IllegalArgumentException.class,
() -> new ExpireManager.ExpireConfig(testItem, "1h", Map.of("command", "ON", "state", "ON")));
}
@Test
void testUnknownConfigKeys() {
Item testItem = new SwitchItem(ITEMNAME);
assertThrows(IllegalArgumentException.class,
() -> new ExpireManager.ExpireConfig(testItem, "1h", Map.of("unknownKey", "unknownValue")));
}
@Test
void testNegativeDuration() {
Item testItem = new SwitchItem(ITEMNAME);
assertThrows(IllegalArgumentException.class, () -> new ExpireManager.ExpireConfig(testItem, "-1h", Map.of()));
assertThrows(IllegalArgumentException.class, () -> new ExpireManager.ExpireConfig(testItem, "-PT1H", Map.of()));
}
private Metadata config(String metadata) {
return new Metadata(METADATA_KEY, metadata, null);
}