diff --git a/bundles/org.openhab.transform.basicprofiles/README.md b/bundles/org.openhab.transform.basicprofiles/README.md
index 52911ca03a..3cceadcd35 100644
--- a/bundles/org.openhab.transform.basicprofiles/README.md
+++ b/bundles/org.openhab.transform.basicprofiles/README.md
@@ -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
The Invert / Negate Profile inverts or negates a command or state.
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceStateProfileConfig.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceStateProfileConfig.java
new file mode 100644
index 0000000000..2f90af3e35
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceStateProfileConfig.java
@@ -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 + "}";
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java
index 0ae22fbc50..d280a40bf0 100644
--- a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java
@@ -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 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 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 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)) {
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceStateProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceStateProfile.java
new file mode 100644
index 0000000000..555facccc5
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceStateProfile.java
@@ -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;
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/debounce-state.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/debounce-state.xml
new file mode 100644
index 0000000000..2169674598
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/debounce-state.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ Delay before an ON (Switch) / OPEN (Contact) value is forwarded to the Item. 0 forwards immediately.
+ 0
+
+
+
+ Delay before an OFF (Switch) / CLOSED (Contact) value is forwarded to the Item. 0 forwards immediately.
+ 0
+
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties
index f74c047194..da618ab3c5 100644
--- a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties
@@ -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
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java
index b9bde87d06..9e67b59ad8 100644
--- a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java
+++ b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java
@@ -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 PROPERTIES = Map.of(ThresholdStateProfile.PARAM_THRESHOLD, 15,
RoundStateProfile.PARAM_SCALE, 2, GenericCommandTriggerProfile.PARAM_EVENTS, "1002,1003",
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceStateProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceStateProfileTest.java
new file mode 100644
index 0000000000..4b28d54075
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceStateProfileTest.java
@@ -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 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());
+ }
+}