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()); + } +}