[basicprofiles] Add State Delay profile (#20929)

* [basicprofiles] Add State Delay profile

Signed-off-by: Andreas Berger <andreas@berger-freelancer.com>
This commit is contained in:
Andreas Berger
2026-06-19 10:30:58 +02:00
committed by GitHub
parent 2da6b67f2c
commit 4270356dee
8 changed files with 475 additions and 6 deletions
@@ -3,11 +3,12 @@
This bundle provides a list of useful profiles:
| Profile | Description |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| [Generic Command Profile](#generic-command-profile) | Sends a command to the Item when an event is triggered |
| [Generic Toggle Switch Profile](#generic-toggle-switch-profile) | Toggles a Switch Item when an event is triggered |
| [Debounce (Counting) Profile](#debounce-counting-profile) | Counts and skips a number of state changes |
| [Debounce (Time) Profile](#debounce-time-profile) | Reduces the frequency of commands or state updates |
| [Debounce (State) Profile](#debounce-state-profile) | Delays ON/OFF (or OPEN/CLOSED) state changes per direction with an independent cooldown |
| [Invert / Negate Profile](#invert-negate-profile) | Inverts or negates a command or state |
| [Round Profile](#round-profile) | Rounds numeric and DateTime input data |
| [Threshold Profile](#threshold-profile) | Translates numeric input data to `ON` or `OFF` based on a threshold value |
@@ -94,6 +95,36 @@ It can be used to debounce Item states/commands or prevent excessive load on net
Number:Temperature debouncedSetpoint { channel="xxx" [profile="basic-profiles:debounce-time", toHandlerDelay=1000] }
```
## Debounce (State) Profile
Delays `ON`/`OFF` (Switch) or `OPEN`/`CLOSED` (Contact) state updates coming from the handler, with an independent delay per direction.
An `ON`/`OPEN` value is held for `onDelay` milliseconds, an `OFF`/`CLOSED` value for `offDelay` milliseconds.
A pending value is cancelled if the opposite value arrives before the delay elapses, and repeated identical values do not restart the timer.
A typical use is a "cooldown" for a flapping binary sensor: forward `ON` immediately (`onDelay=0`) but keep it `ON` for a while after the device reports `OFF` (`offDelay` set to the cooldown), so it does not rapidly switch on and off.
### Debounce (Time) vs. Debounce (State)
Both profiles smooth out noisy inputs, but they target different cases:
- Use **Debounce (Time)** for a single, type-agnostic delay that applies to every value regardless of its content (numbers, strings, switches, …). It is the right choice when you simply want to throttle the update/command frequency of a channel.
- Use **Debounce (State)** for binary `Switch`/`Contact` channels when you need a _different_ delay depending on the direction of the change. Only the active→inactive transition (or vice versa) is delayed, the opposite transition can be forwarded immediately, and an opposing value cancels a still-pending one. This makes it ideal for an asymmetric "cooldown" (e.g. react to `ON` at once but linger on `OFF`) that `debounce-time` cannot express.
### Debounce (State) Profile Configuration
| Configuration Parameter | Type | Description |
|-------------------------|---------|-----------------------------------------------------------------------------------------------|
| `onDelay` | integer | Delay in ms before an `ON` (Switch) / `OPEN` (Contact) value is forwarded. `0` = immediate. |
| `offDelay` | integer | Delay in ms before an `OFF` (Switch) / `CLOSED` (Contact) value is forwarded. `0` = immediate. |
### Debounce (State) Profile Example
Hold the "is it raining" status `ON` for 2 minutes after the device reports dry:
```java
Switch Raining { channel="fineoffsetweatherstation:gateway:xxx:rain-state" [profile="basic-profiles:debounce-state", onDelay=0, offDelay=120000] }
```
## Invert / Negate Profile<a id="invert-negate-profile"></a>
The Invert / Negate Profile inverts or negates a command or state.
@@ -0,0 +1,31 @@
/*
* 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.transform.basicprofiles.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Configuration for {@link org.openhab.transform.basicprofiles.internal.profiles.DebounceStateProfile}.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public class DebounceStateProfileConfig {
public int onDelay = 0;
public int offDelay = 0;
@Override
public String toString() {
return "DebounceStateProfileConfig{onDelay=" + onDelay + ", offDelay=" + offDelay + "}";
}
}
@@ -42,6 +42,7 @@ import org.openhab.core.thing.profiles.i18n.ProfileTypeI18nLocalizationService;
import org.openhab.core.thing.type.ChannelType;
import org.openhab.core.util.BundleResolver;
import org.openhab.transform.basicprofiles.internal.profiles.DebounceCountingStateProfile;
import org.openhab.transform.basicprofiles.internal.profiles.DebounceStateProfile;
import org.openhab.transform.basicprofiles.internal.profiles.DebounceTimeStateProfile;
import org.openhab.transform.basicprofiles.internal.profiles.GenericCommandTriggerProfile;
import org.openhab.transform.basicprofiles.internal.profiles.GenericToggleSwitchTriggerProfile;
@@ -70,6 +71,7 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
public static final ProfileTypeUID GENERIC_TOGGLE_SWITCH_UID = new ProfileTypeUID(SCOPE, "toggle-switch");
public static final ProfileTypeUID DEBOUNCE_COUNTING_UID = new ProfileTypeUID(SCOPE, "debounce-counting");
public static final ProfileTypeUID DEBOUNCE_TIME_UID = new ProfileTypeUID(SCOPE, "debounce-time");
public static final ProfileTypeUID DEBOUNCE_STATE_UID = new ProfileTypeUID(SCOPE, "debounce-state");
public static final ProfileTypeUID INVERT_UID = new ProfileTypeUID(SCOPE, "invert");
public static final ProfileTypeUID ROUND_UID = new ProfileTypeUID(SCOPE, "round");
public static final ProfileTypeUID THRESHOLD_UID = new ProfileTypeUID(SCOPE, "threshold");
@@ -91,6 +93,11 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
.newState(DEBOUNCE_COUNTING_UID, "Debounce (Counting)").build();
private static final ProfileType PROFILE_TYPE_DEBOUNCE_TIME = ProfileTypeBuilder
.newState(DEBOUNCE_TIME_UID, "Debounce (Time)").build();
private static final ProfileType PROFILE_TYPE_DEBOUNCE_STATE = ProfileTypeBuilder
.newState(DEBOUNCE_STATE_UID, "Debounce (State)") //
.withSupportedItemTypes(CoreItemFactory.SWITCH, CoreItemFactory.CONTACT) //
.withSupportedItemTypesOfChannel(CoreItemFactory.SWITCH, CoreItemFactory.CONTACT) //
.build();
private static final ProfileType PROFILE_TYPE_INVERT = ProfileTypeBuilder.newState(INVERT_UID, "Invert / Negate")
.withSupportedItemTypes(CoreItemFactory.CONTACT, CoreItemFactory.DIMMER, CoreItemFactory.NUMBER,
CoreItemFactory.PLAYER, CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.SWITCH) //
@@ -120,12 +127,14 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
.build();
private static final Set<ProfileTypeUID> SUPPORTED_PROFILE_TYPE_UIDS = Set.of(GENERIC_COMMAND_UID,
GENERIC_TOGGLE_SWITCH_UID, DEBOUNCE_COUNTING_UID, DEBOUNCE_TIME_UID, INVERT_UID, ROUND_UID, THRESHOLD_UID,
TIME_RANGE_COMMAND_UID, STATE_FILTER_UID, INACTIVITY_UID, TIME_WEIGHTED_AVERAGE_UID);
GENERIC_TOGGLE_SWITCH_UID, DEBOUNCE_COUNTING_UID, DEBOUNCE_TIME_UID, DEBOUNCE_STATE_UID, INVERT_UID,
ROUND_UID, THRESHOLD_UID, TIME_RANGE_COMMAND_UID, STATE_FILTER_UID, INACTIVITY_UID,
TIME_WEIGHTED_AVERAGE_UID);
private static final Set<ProfileType> SUPPORTED_PROFILE_TYPES = Set.of(PROFILE_TYPE_GENERIC_COMMAND,
PROFILE_TYPE_GENERIC_TOGGLE_SWITCH, PROFILE_TYPE_DEBOUNCE_COUNTING, PROFILE_TYPE_DEBOUNCE_TIME,
PROFILE_TYPE_INVERT, PROFILE_TYPE_ROUND, PROFILE_TYPE_THRESHOLD, PROFILE_TYPE_TIME_RANGE_COMMAND,
PROFILE_STATE_FILTER, PROFILE_TYPE_INACTIVITY, PROFILE_TIME_WEIGHTED_AVERAGE);
PROFILE_TYPE_DEBOUNCE_STATE, PROFILE_TYPE_INVERT, PROFILE_TYPE_ROUND, PROFILE_TYPE_THRESHOLD,
PROFILE_TYPE_TIME_RANGE_COMMAND, PROFILE_STATE_FILTER, PROFILE_TYPE_INACTIVITY,
PROFILE_TIME_WEIGHTED_AVERAGE);
private final Map<LocalizedKey, ProfileType> localizedProfileTypeCache = new ConcurrentHashMap<>();
@@ -158,6 +167,8 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
return new DebounceCountingStateProfile(callback, context);
} else if (DEBOUNCE_TIME_UID.equals(profileTypeUID)) {
return new DebounceTimeStateProfile(callback, context);
} else if (DEBOUNCE_STATE_UID.equals(profileTypeUID)) {
return new DebounceStateProfile(callback, context);
} else if (INVERT_UID.equals(profileTypeUID)) {
return new InvertStateProfile(callback);
} else if (ROUND_UID.equals(profileTypeUID)) {
@@ -0,0 +1,161 @@
/*
* 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.transform.basicprofiles.internal.profiles;
import static org.openhab.transform.basicprofiles.internal.factory.BasicProfilesFactory.DEBOUNCE_STATE_UID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.thing.profiles.ProfileTypeUID;
import org.openhab.core.thing.profiles.StateProfile;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.transform.basicprofiles.internal.config.DebounceStateProfileConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Delays an {@link OnOffType}/{@link OpenClosedType} {@link State} per direction. Active values (ON/OPEN) are held for
* {@code onDelay}, inactive values (OFF/CLOSED) for {@code offDelay}. A pending forward is cancelled by an opposing
* value; repeated identical values do not reschedule.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public class DebounceStateProfile implements StateProfile {
private final Logger logger = LoggerFactory.getLogger(DebounceStateProfile.class);
private final ProfileCallback callback;
private final DebounceStateProfileConfig config;
private final ScheduledExecutorService scheduler;
private @Nullable State lastForwarded;
private @Nullable Boolean pendingActive;
private @Nullable ScheduledFuture<?> pendingJob;
private long pendingGeneration;
public DebounceStateProfile(ProfileCallback callback, ProfileContext context) {
this.callback = callback;
this.scheduler = context.getExecutorService();
this.config = context.getConfiguration().as(DebounceStateProfileConfig.class);
logger.debug("Configuring profile with parameters: {}", config);
if (config.onDelay < 0) {
throw new IllegalArgumentException(
String.format("onDelay has to be a non-negative integer but was '%d'.", config.onDelay));
}
if (config.offDelay < 0) {
throw new IllegalArgumentException(
String.format("offDelay has to be a non-negative integer but was '%d'.", config.offDelay));
}
}
@Override
public ProfileTypeUID getProfileTypeUID() {
return DEBOUNCE_STATE_UID;
}
@Override
public void onStateUpdateFromItem(State state) {
// no-op: this profile only shapes the handler -> item direction
}
@Override
public void onCommandFromItem(Command command) {
callback.handleCommand(command);
}
@Override
public void onCommandFromHandler(Command command) {
// commands are not state and are forwarded immediately; only state updates are delayed
callback.sendCommand(command);
}
@Override
public synchronized void onStateUpdateFromHandler(State state) {
Boolean active = asActive(state);
if (active == null) {
// non-binary values (e.g. UNDEF/NULL) are forwarded immediately and reset the delay state
cancelPending();
doForward(state);
return;
}
// 1. a job for the same value is already pending -> let the running timer continue
if (pendingJob != null && active.equals(pendingActive)) {
return;
}
// 2. a job for the opposite value is pending -> cancel it
if (pendingJob != null) {
cancelPending();
}
// 3. already in the target state -> nothing to forward
State last = lastForwarded;
if (last != null && active.equals(asActive(last))) {
return;
}
// 4. forward now or schedule
int delay = active ? config.onDelay : config.offDelay;
if (delay <= 0) {
doForward(state);
} else {
pendingActive = active;
long generation = ++pendingGeneration;
pendingJob = scheduler.schedule(() -> {
synchronized (DebounceStateProfile.this) {
if (generation != pendingGeneration) {
// superseded or cancelled while we waited for the lock
return;
}
doForward(state);
pendingJob = null;
pendingActive = null;
}
}, delay, TimeUnit.MILLISECONDS);
}
}
private void doForward(State state) {
lastForwarded = state;
callback.sendUpdate(state);
}
private void cancelPending() {
ScheduledFuture<?> job = pendingJob;
if (job != null) {
job.cancel(false);
}
// invalidate any already-running task that is waiting for the lock
pendingGeneration++;
pendingJob = null;
pendingActive = null;
}
private static @Nullable Boolean asActive(State state) {
if (state instanceof OnOffType onOff) {
return onOff == OnOffType.ON;
}
if (state instanceof OpenClosedType openClosed) {
return openClosed == OpenClosedType.OPEN;
}
return null;
}
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="profile:basic-profiles:debounce-state">
<parameter name="onDelay" type="integer" min="0" step="1" unit="ms">
<label>On Delay</label>
<description>Delay before an ON (Switch) / OPEN (Contact) value is forwarded to the Item. 0 forwards immediately.</description>
<default>0</default>
</parameter>
<parameter name="offDelay" type="integer" min="0" step="1" unit="ms">
<label>Off Delay</label>
<description>Delay before an OFF (Switch) / CLOSED (Contact) value is forwarded to the Item. 0 forwards immediately.</description>
<default>0</default>
</parameter>
</config-description>
</config-description:config-descriptions>
@@ -5,6 +5,10 @@ addon.basicprofiles.description = A set of profiles with basic functionality.
profile.config.basic-profiles.debounce-counting.numberOfChanges.label = Number of Changes
profile.config.basic-profiles.debounce-counting.numberOfChanges.description = Number of changes before updating the Item state.
profile.config.basic-profiles.debounce-state.onDelay.label = On Delay
profile.config.basic-profiles.debounce-state.onDelay.description = Delay before an ON (Switch) / OPEN (Contact) value is forwarded to the Item. 0 forwards immediately.
profile.config.basic-profiles.debounce-state.offDelay.label = Off Delay
profile.config.basic-profiles.debounce-state.offDelay.description = Delay before an OFF (Switch) / CLOSED (Contact) value is forwarded to the Item. 0 forwards immediately.
profile.config.basic-profiles.debounce-time.mode.label = Mode
profile.config.basic-profiles.debounce-time.mode.option.FIRST = Send first value
profile.config.basic-profiles.debounce-time.mode.option.LAST = Send last value
@@ -77,6 +81,7 @@ profile.config.basic-profiles.toggle-switch.events.description = Comma-separated
# add-on
profile-type.basic-profiles.debounce-counting.label = Debounce (Counting)
profile-type.basic-profiles.debounce-state.label = Debounce (State)
profile-type.basic-profiles.debounce-time.label = Debounce (Time)
profile-type.basic-profiles.generic-command.label = Generic Command
profile-type.basic-profiles.invert.label = Invert / Negate
@@ -57,7 +57,7 @@ import org.openhab.transform.basicprofiles.internal.profiles.TimeRangeCommandPro
@NonNullByDefault
public class BasicProfilesFactoryTest {
private static final int NUMBER_OF_PROFILES = 11;
private static final int NUMBER_OF_PROFILES = 12;
private static final Map<String, Object> PROPERTIES = Map.of(ThresholdStateProfile.PARAM_THRESHOLD, 15,
RoundStateProfile.PARAM_SCALE, 2, GenericCommandTriggerProfile.PARAM_EVENTS, "1002,1003",
@@ -0,0 +1,211 @@
/*
* 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.transform.basicprofiles.internal.profiles;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.thing.profiles.StateProfile;
/**
* Unit test for {@link DebounceStateProfile}.
*
* @author Andreas Berger - Initial contribution
*/
@ExtendWith(MockitoExtension.class)
@NonNullByDefault
class DebounceStateProfileTest {
private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
private @Mock @NonNullByDefault({}) ProfileContext mockContext;
private @Mock @NonNullByDefault({}) ScheduledExecutorService mockScheduler;
private @Mock @NonNullByDefault({}) ScheduledFuture<?> mockFuture;
private StateProfile initProfile(int onDelay, int offDelay) {
reset(mockContext, mockCallback, mockScheduler);
when(mockContext.getExecutorService()).thenReturn(mockScheduler);
when(mockContext.getConfiguration())
.thenReturn(new Configuration(Map.of("onDelay", onDelay, "offDelay", offDelay)));
lenient().doReturn(mockFuture).when(mockScheduler).schedule(any(Runnable.class), anyLong(),
eq(TimeUnit.MILLISECONDS));
return new DebounceStateProfile(mockCallback, mockContext);
}
private Runnable captureScheduled(int expectedDelay) {
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(mockScheduler).schedule(captor.capture(), eq((long) expectedDelay), eq(TimeUnit.MILLISECONDS));
return captor.getValue();
}
@Test
public void testNegativeOnDelayThrows() {
assertThrows(IllegalArgumentException.class, () -> initProfile(-1, 0));
}
@Test
public void testNegativeOffDelayThrows() {
assertThrows(IllegalArgumentException.class, () -> initProfile(0, -1));
}
@Test
public void testZeroDelayForwardsImmediately() {
StateProfile profile = initProfile(0, 0);
profile.onStateUpdateFromHandler(OnOffType.ON);
verify(mockCallback, times(1)).sendUpdate(OnOffType.ON);
profile.onStateUpdateFromHandler(OnOffType.OFF);
verify(mockCallback, times(1)).sendUpdate(OnOffType.OFF);
verify(mockScheduler, never()).schedule(any(Runnable.class), anyLong(), any());
}
@Test
public void testOffDelayHoldsThenFires() {
StateProfile profile = initProfile(0, 5000);
profile.onStateUpdateFromHandler(OnOffType.ON);
verify(mockCallback, times(1)).sendUpdate(OnOffType.ON);
profile.onStateUpdateFromHandler(OnOffType.OFF);
verify(mockCallback, never()).sendUpdate(OnOffType.OFF);
Runnable job = captureScheduled(5000);
job.run();
verify(mockCallback, times(1)).sendUpdate(OnOffType.OFF);
}
@Test
public void testReturningOnCancelsPendingOff() {
StateProfile profile = initProfile(0, 5000);
profile.onStateUpdateFromHandler(OnOffType.ON);
profile.onStateUpdateFromHandler(OnOffType.OFF);
verify(mockScheduler, times(1)).schedule(any(Runnable.class), eq(5000L), eq(TimeUnit.MILLISECONDS));
profile.onStateUpdateFromHandler(OnOffType.ON);
verify(mockFuture, times(1)).cancel(false);
verify(mockCallback, times(1)).sendUpdate(OnOffType.ON);
verify(mockCallback, never()).sendUpdate(OnOffType.OFF);
}
@Test
public void testStaleCancelledJobDoesNotForward() {
StateProfile profile = initProfile(0, 5000);
profile.onStateUpdateFromHandler(OnOffType.ON);
profile.onStateUpdateFromHandler(OnOffType.OFF);
Runnable staleOffJob = captureScheduled(5000);
// ON returns and cancels the pending OFF before its timer effectively completes
profile.onStateUpdateFromHandler(OnOffType.ON);
// the stale OFF job runs late (it had already started waiting for the lock)
staleOffJob.run();
// it must NOT forward the stale OFF
verify(mockCallback, never()).sendUpdate(OnOffType.OFF);
verify(mockCallback, times(1)).sendUpdate(OnOffType.ON);
}
@Test
public void testRepeatedOffDoesNotReschedule() {
StateProfile profile = initProfile(0, 5000);
profile.onStateUpdateFromHandler(OnOffType.ON);
profile.onStateUpdateFromHandler(OnOffType.OFF);
profile.onStateUpdateFromHandler(OnOffType.OFF);
profile.onStateUpdateFromHandler(OnOffType.OFF);
verify(mockScheduler, times(1)).schedule(any(Runnable.class), eq(5000L), eq(TimeUnit.MILLISECONDS));
}
@Test
public void testOnDelayHoldsThenFires() {
StateProfile profile = initProfile(5000, 0);
profile.onStateUpdateFromHandler(OnOffType.ON);
verify(mockCallback, never()).sendUpdate(OnOffType.ON);
Runnable job = captureScheduled(5000);
job.run();
verify(mockCallback, times(1)).sendUpdate(OnOffType.ON);
}
@Test
public void testOnDelayCancelledByOff() {
StateProfile profile = initProfile(5000, 0);
profile.onStateUpdateFromHandler(OnOffType.ON);
verify(mockScheduler, times(1)).schedule(any(Runnable.class), eq(5000L), eq(TimeUnit.MILLISECONDS));
profile.onStateUpdateFromHandler(OnOffType.OFF);
verify(mockFuture, times(1)).cancel(false);
verify(mockCallback, never()).sendUpdate(OnOffType.ON);
verify(mockCallback, times(1)).sendUpdate(OnOffType.OFF);
}
@Test
public void testContactMapping() {
StateProfile profile = initProfile(0, 5000);
profile.onStateUpdateFromHandler(OpenClosedType.OPEN);
verify(mockCallback, times(1)).sendUpdate(OpenClosedType.OPEN);
profile.onStateUpdateFromHandler(OpenClosedType.CLOSED);
verify(mockCallback, never()).sendUpdate(OpenClosedType.CLOSED);
Runnable job = captureScheduled(5000);
job.run();
verify(mockCallback, times(1)).sendUpdate(OpenClosedType.CLOSED);
}
@Test
public void testCommandsFromHandlerForwardImmediately() {
StateProfile profile = initProfile(5000, 5000);
profile.onCommandFromHandler(OnOffType.ON);
verify(mockCallback, times(1)).sendCommand(OnOffType.ON);
profile.onCommandFromHandler(OnOffType.OFF);
verify(mockCallback, times(1)).sendCommand(OnOffType.OFF);
verify(mockScheduler, never()).schedule(any(Runnable.class), anyLong(), any());
}
@Test
public void testCommandFromHandlerDoesNotAffectPendingStateUpdate() {
StateProfile profile = initProfile(0, 5000);
profile.onStateUpdateFromHandler(OnOffType.ON);
profile.onStateUpdateFromHandler(OnOffType.OFF);
Runnable job = captureScheduled(5000);
// a command arriving while an OFF state update is pending must not interfere with the delay
profile.onCommandFromHandler(OnOffType.ON);
verify(mockCallback, times(1)).sendCommand(OnOffType.ON);
verify(mockFuture, never()).cancel(anyBoolean());
// the pending OFF still fires after its delay
job.run();
verify(mockCallback, times(1)).sendUpdate(OnOffType.OFF);
}
@Test
public void testItemEventsPassThrough() {
StateProfile profile = initProfile(0, 5000);
profile.onCommandFromItem(OnOffType.ON);
verify(mockCallback, times(1)).handleCommand(OnOffType.ON);
profile.onStateUpdateFromItem(OnOffType.ON);
verify(mockCallback, never()).sendUpdate(any());
}
}