<artifactId>org.openhab.persistence.rrd4j</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.transform.basicprofiles</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.transform.bin2json</artifactId>
--- /dev/null
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
+
+== Third-party Content
+
+Parts of this code have been forked from https://github.com/smarthomej/addons
+
+Original license header of forked files was
+
+/**
+ * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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
+ */
--- /dev/null
+# Basic Profiles
+
+This bundle provides a list of useful Profiles.
+
+## Generic Command Profile
+
+This Profile can be used to send a Command towards the Item when one event of a specified event list is triggered.
+The given Command value is parsed either to `IncreaseDecreaseType`, `NextPreviousType`, `OnOffType`, `PlayPauseType`, `RewindFastforwardType`, `StopMoveType`, `UpDownType` or a `StringType` is used.
+
+### Configuration
+
+| Configuration Parameter | Type | Description |
+|-------------------------|------|----------------------------------------------------------------------------------|
+| `events` | text | Comma separated list of events to which the profile should listen. **mandatory** |
+| `command` | text | Command which should be sent if the event is triggered. **mandatory** |
+
+### Full Example
+
+```java
+Switch lightsStatus {
+ channel="hue:0200:XXX:1:color",
+ channel="deconz:switch:YYY:1:buttonevent" [profile="basic-profiles:generic-command", events="1002,1003", command="ON"]
+}
+```
+
+## Generic Toggle Switch Profile
+
+The Generic Toggle Switch Profile is a specialization of the Generic Command Profile and toggles the State of a Switch Item whenever one of the specified events is triggered.
+
+### Configuration
+
+| Configuration Parameter | Type | Description |
+|-------------------------|------|----------------------------------------------------------------------------------|
+| `events` | text | Comma separated list of events to which the profile should listen. **mandatory** |
+
+### Full Example
+
+```java
+Switch lightsStatus {
+ channel="hue:0200:XXX:1:color",
+ channel="deconz:switch:YYY:1:buttonevent" [profile="basic-profiles:toggle-switch", events="1002,1003"]
+}
+```
+
+## Debounce (Counting) Profile
+
+This Profile counts and skips a user-defined number of State changes before it sends an update to the Item.
+It can be used to debounce Item States.
+
+### Configuration
+
+| Configuration Parameter | Type | Description |
+|-------------------------|---------|-----------------------------------------------|
+| `numberOfChanges` | integer | Number of changes before updating Item State. |
+
+### Full Example
+
+```java
+Switch debouncedSwitch { channel="xxx" [profile="basic-profiles:debounce-counting", numberOfChanges=2] }
+```
+
+## Debounce (Time) Profile
+
+In `LAST` mode this profile delays commands or state updates for a configured number of milliseconds and only send the value if no other value is received with that timespan.
+In `FIRST` mode this profile discards values for the configured time after a value is sent.
+
+It can be used to debounce Item States/Commands or prevent excessive load on networks.
+
+### Configuration
+
+| Configuration Parameter | Type | Description |
+|-------------------------|---------|-----------------------------------------------|
+| `toItemDelay` | integer | Timespan in ms before a received value is send to the item. |
+| `toHandlerDelay` | integer | Timespan in ms before a received command is passed to the handler. |
+| `mode` | text | `FIRST` (sends the first value received and discards later values), `LAST` (sends the last value received, discarding earlier values). |
+
+### Full Example
+
+```java
+Number:Temperature debouncedSetpoint { channel="xxx" [profile="basic-profiles:debounce-time", toHandlerDelay=1000] }
+```
+
+## Invert / Negate Profile
+
+The Invert / Negate Profile inverts or negates a Command / State.
+It requires no specific configuration.
+
+The values of `QuantityType`, `PercentType` and `DecimalTypes` are negated (multiplied by `-1`).
+Otherwise the following mapping is used:
+
+`IncreaseDecreaseType`: `INCREASE` <-> `DECREASE`
+`NextPreviousType`: `NEXT` <-> `PREVIOUS`
+`OnOffType`: `ON` <-> `OFF`
+`OpenClosedType`: `OPEN` <-> `CLOSED`
+`PlayPauseType`: `PLAY` <-> `PAUSE`
+`RewindFastforwardType`: `REWIND` <-> `FASTFORWARD`
+`StopMoveType`: `MOVE` <-> `STOP`
+`UpDownType`: `UP` <-> `DOWN`
+
+### Full Example
+
+```java
+Switch invertedSwitch { channel="xxx" [profile="basic-profiles:invert"] }
+```
+
+## Round Profile
+
+The Round Profile scales the State to a specific number of decimal places based on the power of ten.
+Optionally the [Rounding mode](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/math/RoundingMode.html) can be set.
+Source Channels should accept Item Type `Number`.
+
+### Configuration
+
+| Configuration Parameter | Type | Description |
+|-------------------------|---------|-----------------------------------------------------------------------------------------------------------------|
+| `scale` | integer | Scale to indicate the resulting number of decimal places (min: -16, max: 16, STEP: 1) **mandatory**. |
+| `mode` | text | Rounding mode to be used (e.g. "UP", "DOWN", "CEILING", "FLOOR", "HALF_UP" or "HALF_DOWN" (default: "HALF_UP"). |
+
+### Full Example
+
+```java
+Number roundedNumber { channel="xxx" [profile="basic-profiles:round", scale=0] }
+Number:Temperature roundedTemperature { channel="xxx" [profile="basic-profiles:round", scale=1] }
+```
+
+## Threshold Profile
+
+The Threshold Profile triggers `ON` or `OFF` behavior when being linked to a Switch item if value is below a given threshold (default: 10).
+A good use case for this Profile is a battery low indication.
+Source Channels should accept Item Type `Dimmer` or `Number`.
+
+::: tip Note
+This profile is a shortcut for the System Hysteresis Profile.
+:::
+
+### Configuration
+
+| Configuration Parameter | Type | Description |
+|-------------------------|---------|-----------------------------------------------------------------------------------------------------|
+| `threshold` | integer | Triggers `ON` if value is below the given threshold, otherwise OFF (default: 10, min: 0, max: 100). |
+
+### Full Example
+
+```java
+Switch thresholdItem { channel="xxx" [profile="basic-profiles:threshold", threshold=15] }
+```
+
+## Time Range Command Profile
+
+This is an enhanced implementation of a follow profile which converts `OnOffType` to a `PercentType`.
+The value of the percent type can be different between a specific time of the day.
+A possible use-case is switching lights (using a presence detector) with different intensities at day and at night.
+Be aware: a range beyond midnight (e.g. start="23:00", end="01:00") is not yet supported.
+
+### Configuration
+
+| Configuration Parameter | Type | Description |
+|-------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------|
+| `inRangeValue` | integer | The value which will be send when the profile detects ON and current time is between start time and end time (default: 100, min: 0, max: 100). |
+| `outOfRangeValue` | integer | The value which will be send when the profile detects ON and current time is NOT between start time and end time (default: 30, min: 0, max: 100). |
+| `start` | text | The start time of the day (hh:mm). |
+| `end` | text | The end time of the day (hh:mm). |
+| `restoreValue` | text | Select what should happen when the profile detects OFF again (default: OFF). |
+
+Possible values for parameter `restoreValue`:
+
+- `OFF` - Turn the light off
+- `NOTHING` - Do nothing
+- `PREVIOUS` - Return to previous value
+- `0` - `100` - Set a user-defined percent value
+
+### Full Example
+
+```java
+Switch motionSensorFirstFloor {
+ channel="deconz:presencesensor:XXX:YYY:presence",
+ channel="deconz:colortemperaturelight:AAA:BBB:brightness" [profile="basic-profiles:time-range-command", inRangeValue=100, outOfRangeValue=15, start="08:00", end="23:00", restoreValue="PREVIOUS"]
+}
+```
+
+## State Filter Profile
+
+This filter passes on state updates from a (binding) handler to the item if and only if all listed item state conditions
+are met (conditions are ANDed together).
+Option to instead pass different state update in case the conditions are not met.
+State values may be quoted to treat as `StringType`.
+
+Use case: Ignore values from a binding unless some other item(s) have a specific state.
+
+### Configuration
+
+| Configuration Parameter | Type | Description |
+|-------------------------|------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `conditions` | text | Comma separated list of expressions on the format `ITEM_NAME OPERATOR ITEM_STATE`, ie `MyItem EQ OFF`. Use quotes around `ITEM_STATE` to treat value as string ie `'OFF'` and not `OnOffType.OFF` |
+| `mismatchState` | text | Optional state to pass instead if conditions are NOT met. Use single quotes to treat as `StringType`. Defaults to `UNDEF` |
+| `separator` | text | Optional separator string to separate expressions when using multiple. Defaults to `,` |
+
+Possible values for token `OPERATOR` in `conditions`:
+
+- `EQ` - Equals
+- `NEQ` - Not equals
+
+
+### Full Example
+
+```Java
+Number:Temperature airconTemperature{
+ channel="mybinding:mything:mychannel"[profile="basic-profiles:state-filter",conditions="airconPower_item EQ ON",mismatchState="UNDEF"]
+}
+```
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+ <version>4.2.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>org.openhab.transform.basicprofiles</artifactId>
+
+ <name>openHAB Add-ons :: Bundles :: Transformation Service :: Basic Profiles</name>
+
+</project>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.transform.basicprofiles-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+ <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+ <feature name="openhab-transformation-basicprofiles" description="Basic Profiles" version="${project.version}">
+ <feature>openhab-runtime-base</feature>
+ <bundle start-level="75">mvn:org.openhab.addons.bundles/org.openhab.transform.basicprofiles/${project.version}</bundle>
+ </feature>
+</features>
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link BasicProfilesConstants} class defines common constants, which are used across the whole bundle.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class BasicProfilesConstants {
+ public static final String SCOPE = "basic-profiles";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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;
+import org.openhab.transform.basicprofiles.internal.profiles.DebounceCountingStateProfile;
+
+/**
+ * Configuration for {@link DebounceCountingStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class DebounceCountingStateProfileConfig {
+ public int numberOfChanges = 1;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.DebounceTimeStateProfile}.
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class DebounceTimeStateProfileConfig {
+ public int toHandlerDelay = 0;
+ public int toItemDelay = 0;
+ public DebounceMode mode = DebounceMode.LAST;
+
+ @Override
+ public String toString() {
+ return "DebounceTimeStateProfileConfig{toHandlerDelay=" + toHandlerDelay + ", toItemDelay=" + toItemDelay
+ + ", mode=" + mode + "}";
+ }
+
+ public enum DebounceMode {
+ FIRST,
+ LAST
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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 java.math.RoundingMode;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.transform.basicprofiles.internal.profiles.RoundStateProfile;
+
+/**
+ * Configuration for {@link RoundStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class RoundStateProfileConfig {
+ public @Nullable Integer scale;
+ public String mode = RoundingMode.HALF_UP.name();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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;
+import org.openhab.core.types.UnDefType;
+import org.openhab.transform.basicprofiles.internal.profiles.StateFilterProfile;
+
+/**
+ * Configuration class for {@link StateFilterProfile}.
+ *
+ * @author Arne Seime - Initial contribution
+ */
+@NonNullByDefault
+public class StateFilterProfileConfig {
+
+ public String conditions = "";
+
+ public String mismatchState = UnDefType.UNDEF.toString();
+
+ public String separator = ",";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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;
+import org.openhab.transform.basicprofiles.internal.profiles.ThresholdStateProfile;
+
+/**
+ * Configuration for {@link ThresholdStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class ThresholdStateProfileConfig {
+ public int threshold = 10;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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;
+import org.openhab.transform.basicprofiles.internal.profiles.TimeRangeCommandProfile;
+
+/**
+ * Configuration for {@link TimeRangeCommandProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class TimeRangeCommandProfileConfig {
+ public int inRangeValue = 100;
+ public int outOfRangeValue = 30;
+ public @NonNullByDefault({}) String start;
+ public @NonNullByDefault({}) String end;
+ public String restoreValue = TimeRangeCommandProfile.CONFIG_RESTORE_VALUE_OFF;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.factory;
+
+import static org.openhab.transform.basicprofiles.internal.BasicProfilesConstants.SCOPE;
+
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.i18n.LocalizedKey;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.library.CoreItemFactory;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
+import org.openhab.core.thing.profiles.Profile;
+import org.openhab.core.thing.profiles.ProfileAdvisor;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.ProfileFactory;
+import org.openhab.core.thing.profiles.ProfileType;
+import org.openhab.core.thing.profiles.ProfileTypeBuilder;
+import org.openhab.core.thing.profiles.ProfileTypeProvider;
+import org.openhab.core.thing.profiles.ProfileTypeUID;
+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.DebounceTimeStateProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.GenericCommandTriggerProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.GenericToggleSwitchTriggerProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.InvertStateProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.RoundStateProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.StateFilterProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.ThresholdStateProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.TimeRangeCommandProfile;
+import org.osgi.framework.Bundle;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link BasicProfilesFactory} is responsible for creating profiles.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@Component(service = { ProfileFactory.class, ProfileTypeProvider.class })
+@NonNullByDefault
+public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider, ProfileAdvisor {
+
+ public static final ProfileTypeUID GENERIC_COMMAND_UID = new ProfileTypeUID(SCOPE, "generic-command");
+ 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 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");
+ public static final ProfileTypeUID TIME_RANGE_COMMAND_UID = new ProfileTypeUID(SCOPE, "time-range-command");
+ public static final ProfileTypeUID STATE_FILTER_UID = new ProfileTypeUID(SCOPE, "state-filter");
+
+ private static final ProfileType PROFILE_TYPE_GENERIC_COMMAND = ProfileTypeBuilder
+ .newTrigger(GENERIC_COMMAND_UID, "Generic Command") //
+ .withSupportedItemTypes(CoreItemFactory.DIMMER, CoreItemFactory.NUMBER, CoreItemFactory.PLAYER,
+ CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.SWITCH) //
+ .build();
+ private static final ProfileType PROFILE_TYPE_GENERIC_TOGGLE_SWITCH = ProfileTypeBuilder
+ .newTrigger(GENERIC_TOGGLE_SWITCH_UID, "Generic Toggle Switch") //
+ .withSupportedItemTypes(CoreItemFactory.COLOR, CoreItemFactory.DIMMER, CoreItemFactory.SWITCH) //
+ .build();
+ private static final ProfileType PROFILE_TYPE_DEBOUNCE_COUNTING = ProfileTypeBuilder
+ .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_INVERT = ProfileTypeBuilder.newState(INVERT_UID, "Invert / Negate")
+ .withSupportedItemTypes(CoreItemFactory.CONTACT, CoreItemFactory.DIMMER, CoreItemFactory.NUMBER,
+ CoreItemFactory.PLAYER, CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.SWITCH) //
+ .withSupportedItemTypesOfChannel(CoreItemFactory.CONTACT, CoreItemFactory.DIMMER, CoreItemFactory.NUMBER,
+ CoreItemFactory.PLAYER, CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.SWITCH) //
+ .build();
+ private static final ProfileType PROFILE_TYPE_ROUND = ProfileTypeBuilder.newState(ROUND_UID, "Round")
+ .withSupportedItemTypes(CoreItemFactory.NUMBER) //
+ .withSupportedItemTypesOfChannel(CoreItemFactory.NUMBER) //
+ .build();
+ private static final ProfileType PROFILE_TYPE_THRESHOLD = ProfileTypeBuilder.newState(THRESHOLD_UID, "Threshold") //
+ .withSupportedItemTypesOfChannel(CoreItemFactory.DIMMER, CoreItemFactory.NUMBER) //
+ .withSupportedItemTypes(CoreItemFactory.SWITCH) //
+ .build();
+ private static final ProfileType PROFILE_TYPE_TIME_RANGE_COMMAND = ProfileTypeBuilder
+ .newState(TIME_RANGE_COMMAND_UID, "Time Range Command") //
+ .withSupportedItemTypes(CoreItemFactory.SWITCH) //
+ .withSupportedChannelTypeUIDs(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_MOTION) //
+ .build();
+ private static final ProfileType PROFILE_STATE_FILTER = ProfileTypeBuilder
+ .newState(STATE_FILTER_UID, "Filter handler state updates based on any item state").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);
+ 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);
+
+ private final Map<LocalizedKey, ProfileType> localizedProfileTypeCache = new ConcurrentHashMap<>();
+
+ private final ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService;
+ private final Bundle bundle;
+ private final ItemRegistry itemRegistry;
+ private final TimeZoneProvider timeZoneProvider;
+
+ @Activate
+ public BasicProfilesFactory(final @Reference ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService,
+ final @Reference BundleResolver bundleResolver, @Reference ItemRegistry itemRegistry,
+ @Reference TimeZoneProvider timeZoneProvider) {
+ this.profileTypeI18nLocalizationService = profileTypeI18nLocalizationService;
+ this.bundle = bundleResolver.resolveBundle(BasicProfilesFactory.class);
+ this.itemRegistry = itemRegistry;
+ this.timeZoneProvider = timeZoneProvider;
+ }
+
+ @Override
+ public @Nullable Profile createProfile(ProfileTypeUID profileTypeUID, ProfileCallback callback,
+ ProfileContext context) {
+ if (GENERIC_COMMAND_UID.equals(profileTypeUID)) {
+ return new GenericCommandTriggerProfile(callback, context);
+ } else if (GENERIC_TOGGLE_SWITCH_UID.equals(profileTypeUID)) {
+ return new GenericToggleSwitchTriggerProfile(callback, context);
+ } else if (DEBOUNCE_COUNTING_UID.equals(profileTypeUID)) {
+ return new DebounceCountingStateProfile(callback, context);
+ } else if (DEBOUNCE_TIME_UID.equals(profileTypeUID)) {
+ return new DebounceTimeStateProfile(callback, context);
+ } else if (INVERT_UID.equals(profileTypeUID)) {
+ return new InvertStateProfile(callback);
+ } else if (ROUND_UID.equals(profileTypeUID)) {
+ return new RoundStateProfile(callback, context);
+ } else if (THRESHOLD_UID.equals(profileTypeUID)) {
+ return new ThresholdStateProfile(callback, context);
+ } else if (TIME_RANGE_COMMAND_UID.equals(profileTypeUID)) {
+ return new TimeRangeCommandProfile(callback, context, timeZoneProvider);
+ } else if (STATE_FILTER_UID.equals(profileTypeUID)) {
+ return new StateFilterProfile(callback, context, itemRegistry);
+ }
+ return null;
+ }
+
+ @Override
+ public Collection<ProfileType> getProfileTypes(@Nullable Locale locale) {
+ return SUPPORTED_PROFILE_TYPES.stream().map(p -> createLocalizedProfileType(p, locale)).toList();
+ }
+
+ @Override
+ public Collection<ProfileTypeUID> getSupportedProfileTypeUIDs() {
+ return SUPPORTED_PROFILE_TYPE_UIDS;
+ }
+
+ @Override
+ public @Nullable ProfileTypeUID getSuggestedProfileTypeUID(ChannelType channelType, @Nullable String itemType) {
+ return null;
+ }
+
+ @Override
+ public @Nullable ProfileTypeUID getSuggestedProfileTypeUID(Channel channel, @Nullable String itemType) {
+ return null;
+ }
+
+ private ProfileType createLocalizedProfileType(ProfileType profileType, @Nullable Locale locale) {
+ final LocalizedKey localizedKey = new LocalizedKey(profileType.getUID(),
+ locale != null ? locale.toLanguageTag() : null);
+
+ final ProfileType cachedlocalizedProfileType = localizedProfileTypeCache.get(localizedKey);
+ if (cachedlocalizedProfileType != null) {
+ return cachedlocalizedProfileType;
+ }
+
+ final ProfileType localizedProfileType = profileTypeI18nLocalizationService.createLocalizedProfileType(bundle,
+ profileType, locale);
+ if (localizedProfileType != null) {
+ localizedProfileTypeCache.put(localizedKey, localizedProfileType);
+ return localizedProfileType;
+ } else {
+ return profileType;
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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 java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.TriggerProfile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link AbstractTriggerProfile} class implements the behavior when being linked to an item.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public abstract class AbstractTriggerProfile implements TriggerProfile {
+
+ private final Logger logger = LoggerFactory.getLogger(AbstractTriggerProfile.class);
+
+ public static final String PARAM_EVENTS = "events";
+
+ final ProfileCallback callback;
+ final List<String> events;
+
+ @SuppressWarnings("unchecked")
+ AbstractTriggerProfile(ProfileCallback callback, ProfileContext context) {
+ this.callback = callback;
+
+ Object paramValue = context.getConfiguration().get(PARAM_EVENTS);
+ logger.trace("Configuring profile '{}' with '{}' parameter: '{}'", getProfileTypeUID(), PARAM_EVENTS,
+ paramValue);
+ if (paramValue instanceof String) {
+ String event = paramValue.toString();
+ events = Collections.unmodifiableList(Arrays.asList(event.split(",")));
+ } else if (paramValue instanceof Iterable) {
+ List<String> values = new ArrayList<>();
+ for (String event : (Iterable<String>) paramValue) {
+ values.add(event);
+ }
+ events = Collections.unmodifiableList(values);
+ } else {
+ logger.error("Parameter '{}' is not a comma separated list of Strings", PARAM_EVENTS);
+ events = List.of();
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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_COUNTING_UID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+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.DebounceCountingStateProfileConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Debounces a {@link State} by counting.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class DebounceCountingStateProfile implements StateProfile {
+
+ private final Logger logger = LoggerFactory.getLogger(DebounceCountingStateProfile.class);
+
+ private final ProfileCallback callback;
+
+ private final int numberOfChanges;
+ private int counter;
+
+ private @Nullable State previousState;
+
+ public DebounceCountingStateProfile(ProfileCallback callback, ProfileContext context) {
+ this.callback = callback;
+ DebounceCountingStateProfileConfig config = context.getConfiguration()
+ .as(DebounceCountingStateProfileConfig.class);
+ logger.debug("Configuring profile with parameters: [numberOfChanges='{}']", config.numberOfChanges);
+
+ if (config.numberOfChanges < 0) {
+ throw new IllegalArgumentException(String
+ .format("numberOfChanges has to be a non-negative integer but was '%d'.", config.numberOfChanges));
+ }
+
+ this.numberOfChanges = config.numberOfChanges;
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return DEBOUNCE_COUNTING_UID;
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ this.previousState = state;
+ }
+
+ @Override
+ public void onCommandFromItem(Command command) {
+ // no-op
+ }
+
+ @Override
+ public void onCommandFromHandler(Command command) {
+ // no-op
+ }
+
+ @Override
+ public void onStateUpdateFromHandler(State state) {
+ logger.debug("Received state update from Handler");
+ State localPreviousState = previousState;
+ if (localPreviousState == null) {
+ callback.sendUpdate(state);
+ previousState = state;
+ } else {
+ if (state.equals(localPreviousState.as(state.getClass()))) {
+ logger.debug("Item state back to previous state, reset counter");
+ callback.sendUpdate(localPreviousState);
+ counter = 0;
+ } else {
+ logger.debug("Item state changed, counting");
+ counter++;
+ if (numberOfChanges < counter) {
+ logger.debug("Item state has changed {} times, send new state to Item", counter);
+ callback.sendUpdate(state);
+ previousState = state;
+ counter = 0;
+ } else {
+ callback.sendUpdate(localPreviousState);
+ }
+ }
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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_TIME_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.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.DebounceTimeStateProfileConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Debounces a {@link State} by time.
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class DebounceTimeStateProfile implements StateProfile {
+ private final Logger logger = LoggerFactory.getLogger(DebounceTimeStateProfile.class);
+
+ private final ProfileCallback callback;
+ private final DebounceTimeStateProfileConfig config;
+ private final ScheduledExecutorService scheduler;
+
+ private @Nullable ScheduledFuture<?> toHandlerJob;
+ private @Nullable ScheduledFuture<?> toItemJob;
+
+ public DebounceTimeStateProfile(ProfileCallback callback, ProfileContext context) {
+ this.callback = callback;
+ this.scheduler = context.getExecutorService();
+ this.config = context.getConfiguration().as(DebounceTimeStateProfileConfig.class);
+ logger.debug("Configuring profile with parameters: {}", config);
+
+ if (config.toHandlerDelay < 0) {
+ throw new IllegalArgumentException(String
+ .format("toHandlerDelay has to be a non-negative integer but was '%d'.", config.toHandlerDelay));
+ }
+
+ if (config.toItemDelay < 0) {
+ throw new IllegalArgumentException(
+ String.format("toItemDelay has to be a non-negative integer but was '%d'.", config.toItemDelay));
+ }
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return DEBOUNCE_TIME_UID;
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ // no-op
+ }
+
+ @Override
+ public void onCommandFromItem(Command command) {
+ logger.debug("Received command '{}' from item", command);
+ if (config.toHandlerDelay == 0) {
+ callback.handleCommand(command);
+ return;
+ }
+ ScheduledFuture<?> localToHandlerJob = toHandlerJob;
+ if (config.mode == DebounceTimeStateProfileConfig.DebounceMode.LAST) {
+ if (localToHandlerJob != null) {
+ // if we have an old job, cancel it
+ localToHandlerJob.cancel(true);
+ }
+ logger.trace("Scheduling command '{}'", command);
+ scheduleToHandler(() -> {
+ logger.debug("Sending command '{}' to handler", command);
+ callback.handleCommand(command);
+ });
+ } else {
+ if (localToHandlerJob == null) {
+ // send the value only if we don't have a job
+ callback.handleCommand(command);
+ scheduleToHandler(null);
+ } else {
+ logger.trace("Discarding command to handler '{}'", command);
+ }
+ }
+ }
+
+ private void scheduleToHandler(@Nullable Runnable function) {
+ toHandlerJob = scheduler.schedule(() -> {
+ if (function != null) {
+ function.run();
+ }
+ toHandlerJob = null;
+ }, config.toHandlerDelay, TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public void onCommandFromHandler(Command command) {
+ logger.debug("Received command '{}' from handler", command);
+ if (config.toItemDelay == 0) {
+ callback.sendCommand(command);
+ return;
+ }
+
+ ScheduledFuture<?> localToItemJob = toItemJob;
+ if (config.mode == DebounceTimeStateProfileConfig.DebounceMode.LAST) {
+ if (localToItemJob != null) {
+ // if we have an old job, cancel it
+ localToItemJob.cancel(true);
+ }
+ logger.trace("Scheduling command '{}' to item", command);
+ scheduleToItem(() -> {
+ logger.debug("Sending command '{}' to item", command);
+ callback.sendCommand(command);
+ });
+ } else {
+ if (localToItemJob == null) {
+ // only schedule a new job if we have none
+ callback.sendCommand(command);
+ scheduleToItem(null);
+ } else {
+ logger.trace("Discarding command to item '{}'", command);
+ }
+ }
+ }
+
+ @Override
+ public void onStateUpdateFromHandler(State state) {
+ logger.debug("Received state update from Handler");
+ if (config.toItemDelay == 0) {
+ callback.sendUpdate(state);
+ return;
+ }
+ ScheduledFuture<?> localToItemJob = toItemJob;
+ if (config.mode == DebounceTimeStateProfileConfig.DebounceMode.LAST) {
+ if (localToItemJob != null) {
+ // if we have an old job, cancel it
+ localToItemJob.cancel(true);
+ }
+ logger.trace("Scheduling state update '{}' to item", state);
+ scheduleToItem(() -> {
+ logger.debug("Sending state update '{}' to item", state);
+ callback.sendUpdate(state);
+ });
+ } else {
+ if (toItemJob == null) {
+ // only schedule a new job if we have none
+ callback.sendUpdate(state);
+ scheduleToItem(null);
+ } else {
+ logger.trace("Discarding state update to item '{}'", state);
+ }
+ }
+ }
+
+ private void scheduleToItem(@Nullable Runnable function) {
+ toItemJob = scheduler.schedule(() -> {
+ if (function != null) {
+ function.run();
+ }
+ toItemJob = null;
+ }, config.toItemDelay, TimeUnit.MILLISECONDS);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.GENERIC_COMMAND_UID;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.IncreaseDecreaseType;
+import org.openhab.core.library.types.NextPreviousType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.RewindFastforwardType;
+import org.openhab.core.library.types.StopMoveType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
+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.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.TypeParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link GenericCommandTriggerProfile} class implements the behavior when being linked to an item.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class GenericCommandTriggerProfile extends AbstractTriggerProfile {
+
+ private final Logger logger = LoggerFactory.getLogger(GenericCommandTriggerProfile.class);
+
+ private static final List<Class<? extends Command>> SUPPORTED_COMMANDS = List.of(IncreaseDecreaseType.class,
+ NextPreviousType.class, OnOffType.class, PlayPauseType.class, RewindFastforwardType.class,
+ StopMoveType.class, UpDownType.class);
+
+ public static final String PARAM_COMMAND = "command";
+
+ private @Nullable Command command;
+
+ public GenericCommandTriggerProfile(ProfileCallback callback, ProfileContext context) {
+ super(callback, context);
+
+ Object paramValue = context.getConfiguration().get(PARAM_COMMAND);
+ logger.trace("Configuring profile '{}' with '{}' parameter: '{}'", getProfileTypeUID(), PARAM_COMMAND,
+ paramValue);
+ if (paramValue instanceof String value) {
+ command = TypeParser.parseCommand(SUPPORTED_COMMANDS, value);
+ if (command == null) {
+ logger.debug("Value '{}' for parameter '{}' is a not supported command. Using StringType instead.",
+ value, PARAM_COMMAND);
+ command = StringType.valueOf(value);
+ }
+ } else {
+ logger.error("Parameter '{}' is not of type String: {}", PARAM_COMMAND, paramValue);
+ }
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return GENERIC_COMMAND_UID;
+ }
+
+ @Override
+ public void onTriggerFromHandler(String payload) {
+ Command c = command;
+ if (c != null && events.contains(payload)) {
+ callback.sendCommand(c);
+ }
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ // do nothing
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.GENERIC_TOGGLE_SWITCH_UID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.OnOffType;
+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.types.State;
+
+/**
+ * The {@link GenericToggleSwitchTriggerProfile} class implements the behavior when being linked to an item.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class GenericToggleSwitchTriggerProfile extends AbstractTriggerProfile {
+
+ private @Nullable State previousState;
+
+ public GenericToggleSwitchTriggerProfile(ProfileCallback callback, ProfileContext context) {
+ super(callback, context);
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return GENERIC_TOGGLE_SWITCH_UID;
+ }
+
+ @Override
+ public void onTriggerFromHandler(String payload) {
+ if (events.contains(payload)) {
+ OnOffType state = OnOffType.ON.equals(previousState) ? OnOffType.OFF : OnOffType.ON;
+ callback.sendCommand(state);
+ previousState = state;
+ }
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ previousState = state.as(OnOffType.class);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.INVERT_UID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.IncreaseDecreaseType;
+import org.openhab.core.library.types.NextPreviousType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.RewindFastforwardType;
+import org.openhab.core.library.types.StopMoveType;
+import org.openhab.core.library.types.UpDownType;
+import org.openhab.core.thing.profiles.ProfileCallback;
+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.core.types.Type;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Inverts a {@link Command} or {@link State}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class InvertStateProfile implements StateProfile {
+
+ private final Logger logger = LoggerFactory.getLogger(InvertStateProfile.class);
+
+ private final ProfileCallback callback;
+
+ public InvertStateProfile(ProfileCallback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return INVERT_UID;
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ // do nothing
+ }
+
+ @Override
+ public void onCommandFromItem(Command command) {
+ callback.handleCommand((Command) invert(command));
+ }
+
+ @Override
+ public void onCommandFromHandler(Command command) {
+ callback.sendCommand((Command) invert(command));
+ }
+
+ @Override
+ public void onStateUpdateFromHandler(State state) {
+ callback.sendUpdate((State) invert(state));
+ }
+
+ private Type invert(Type type) {
+ if (type instanceof UnDefType) {
+ // we cannot invert UNDEF or NULL values, thus we simply return them without reporting an error or warning
+ return type;
+ }
+
+ if (type instanceof QuantityType<?> qtState) {
+ return qtState.negate();
+ } else if (type instanceof PercentType ptState) {
+ return new PercentType(100 - ptState.intValue());
+ } else if (type instanceof DecimalType dtState) {
+ return new DecimalType(-1 * dtState.doubleValue());
+ } else if (type instanceof IncreaseDecreaseType) {
+ return IncreaseDecreaseType.INCREASE.equals(type) ? IncreaseDecreaseType.DECREASE
+ : IncreaseDecreaseType.INCREASE;
+ } else if (type instanceof NextPreviousType) {
+ return NextPreviousType.NEXT.equals(type) ? NextPreviousType.PREVIOUS : NextPreviousType.NEXT;
+ } else if (type instanceof OnOffType) {
+ return OnOffType.ON.equals(type) ? OnOffType.OFF : OnOffType.ON;
+ } else if (type instanceof OpenClosedType) {
+ return OpenClosedType.OPEN.equals(type) ? OpenClosedType.CLOSED : OpenClosedType.OPEN;
+ } else if (type instanceof PlayPauseType) {
+ return PlayPauseType.PLAY.equals(type) ? PlayPauseType.PAUSE : PlayPauseType.PLAY;
+ } else if (type instanceof RewindFastforwardType) {
+ return RewindFastforwardType.REWIND.equals(type) ? RewindFastforwardType.FASTFORWARD
+ : RewindFastforwardType.REWIND;
+ } else if (type instanceof StopMoveType) {
+ return StopMoveType.MOVE.equals(type) ? StopMoveType.STOP : StopMoveType.MOVE;
+ } else if (type instanceof UpDownType) {
+ return UpDownType.UP.equals(type) ? UpDownType.DOWN : UpDownType.UP;
+ } else {
+ logger.warn("Invert cannot be applied to the type of class '{}'. Returning original type.",
+ type.getClass());
+ return type;
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.ROUND_UID;
+
+import java.math.RoundingMode;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.QuantityType;
+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.core.types.Type;
+import org.openhab.core.types.UnDefType;
+import org.openhab.transform.basicprofiles.internal.config.RoundStateProfileConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Applies rounding with the specified scale and the rounding mode to a {@link QuantityType} or {@link DecimalType}
+ * state. Default rounding mode is {@link RoundingMode#HALF_UP}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class RoundStateProfile implements StateProfile {
+
+ private final Logger logger = LoggerFactory.getLogger(RoundStateProfile.class);
+
+ public static final String PARAM_SCALE = "scale";
+ public static final String PARAM_MODE = "mode";
+
+ private final ProfileCallback callback;
+
+ final int scale;
+ final RoundingMode roundingMode;
+
+ public RoundStateProfile(ProfileCallback callback, ProfileContext context) {
+ this.callback = callback;
+ RoundStateProfileConfig config = context.getConfiguration().as(RoundStateProfileConfig.class);
+ logger.debug("Configuring profile with parameters: [{scale='{}', mode='{}']", config.scale, config.mode);
+
+ int localScale = 0;
+ Integer configScale = config.scale;
+ if (configScale != null) {
+ localScale = ((Number) configScale).intValue();
+ } else {
+ logger.error("Parameter 'scale' is not of type String or Number.");
+ }
+
+ RoundingMode localRoundingMode = RoundingMode.HALF_UP;
+ if (config.mode instanceof String) {
+ try {
+ localRoundingMode = RoundingMode.valueOf(config.mode);
+ } catch (IllegalArgumentException e) {
+ logger.warn("Parameter 'mode' is not a supported rounding mode: '{}'. Using default.", config.mode);
+ }
+ } else {
+ logger.error("Parameter 'mode' is not of type String.");
+ }
+
+ this.scale = localScale;
+ this.roundingMode = localRoundingMode;
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return ROUND_UID;
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ // do nothing
+ }
+
+ @Override
+ public void onCommandFromItem(Command command) {
+ callback.handleCommand((Command) applyRound(command));
+ }
+
+ @Override
+ public void onCommandFromHandler(Command command) {
+ callback.sendCommand((Command) applyRound(command));
+ }
+
+ @Override
+ public void onStateUpdateFromHandler(State state) {
+ callback.sendUpdate((State) applyRound(state));
+ }
+
+ private Type applyRound(Type state) {
+ if (state instanceof UnDefType) {
+ // we cannot round UNDEF or NULL values, thus we simply return them without reporting an error or warning
+ return state;
+ }
+
+ Type result = UnDefType.UNDEF;
+ if (state instanceof QuantityType<?> qtState) {
+ result = new QuantityType<>(qtState.toBigDecimal().setScale(scale, roundingMode), qtState.getUnit());
+ } else if (state instanceof DecimalType dtState) {
+ result = new DecimalType(dtState.toBigDecimal().setScale(scale, roundingMode));
+ } else {
+ logger.warn(
+ "Round cannot be applied to the incompatible state '{}' sent from the binding. Returning original state.",
+ state);
+ result = state;
+ }
+ return result;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.STATE_FILTER_UID;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.items.Item;
+import org.openhab.core.items.ItemNotFoundException;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.library.types.StringType;
+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.core.types.TypeParser;
+import org.openhab.transform.basicprofiles.internal.config.StateFilterProfileConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Accepts updates to state as long as conditions are met. Support for sending fixed state if conditions are *not*
+ * met.
+ *
+ * @author Arne Seime - Initial contribution
+ */
+@NonNullByDefault
+public class StateFilterProfile implements StateProfile {
+
+ private final Logger logger = LoggerFactory.getLogger(StateFilterProfile.class);
+
+ private final ItemRegistry itemRegistry;
+ private final ProfileCallback callback;
+ private List<Class<? extends State>> acceptedDataTypes;
+
+ private List<StateCondition> conditions = List.of();
+
+ private @Nullable State configMismatchState = null;
+
+ public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
+ this.callback = callback;
+ acceptedDataTypes = context.getAcceptedDataTypes();
+ this.itemRegistry = itemRegistry;
+
+ StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
+ if (config != null) {
+ conditions = parseConditions(config.conditions, config.separator);
+ configMismatchState = parseState(config.mismatchState);
+ }
+ }
+
+ private List<StateCondition> parseConditions(@Nullable String config, String separator) {
+ if (config == null) {
+ return List.of();
+ }
+
+ List<StateCondition> parsedConditions = new ArrayList<>();
+ try {
+ String[] expressions = config.split(separator);
+ for (String expression : expressions) {
+ String[] parts = expression.trim().split("\s");
+ if (parts.length == 3) {
+ String itemName = parts[0];
+ StateCondition.ComparisonType conditionType = StateCondition.ComparisonType
+ .valueOf(parts[1].toUpperCase(Locale.ROOT));
+ String value = parts[2];
+ parsedConditions.add(new StateCondition(itemName, conditionType, value));
+ } else {
+ logger.warn("Malformed condition expression: '{}'", expression);
+ }
+ }
+
+ return parsedConditions;
+ } catch (IllegalArgumentException e) {
+ logger.warn("Cannot parse condition {}. Expected format ITEM_NAME <EQ|NEQ> STATE_VALUE: '{}'", config,
+ e.getMessage());
+ return List.of();
+ }
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return STATE_FILTER_UID;
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ // do nothing
+ }
+
+ @Override
+ public void onCommandFromItem(Command command) {
+ callback.handleCommand(command);
+ }
+
+ @Override
+ public void onCommandFromHandler(Command command) {
+ callback.sendCommand(command);
+ }
+
+ @Override
+ public void onStateUpdateFromHandler(State state) {
+ State resultState = checkCondition(state);
+ if (resultState != null) {
+ logger.debug("Received state update from handler: {}, forwarded as {}", state, resultState);
+ callback.sendUpdate(resultState);
+ } else {
+ logger.debug("Received state update from handler: {}, not forwarded to item", state);
+ }
+ }
+
+ @Nullable
+ private State checkCondition(State state) {
+ if (!conditions.isEmpty()) {
+ boolean allConditionsMet = true;
+ for (StateCondition condition : conditions) {
+ logger.debug("Evaluting condition: {}", condition);
+ try {
+ Item item = itemRegistry.getItem(condition.itemName);
+ String itemState = item.getState().toString();
+
+ if (!condition.matches(itemState)) {
+ allConditionsMet = false;
+ }
+ } catch (ItemNotFoundException e) {
+ logger.warn(
+ "Cannot find item '{}' in registry - check your condition expression - skipping state update",
+ condition.itemName);
+ allConditionsMet = false;
+ }
+
+ }
+ if (allConditionsMet) {
+ return state;
+ } else {
+ return configMismatchState;
+ }
+ } else {
+ logger.warn(
+ "No configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update");
+ }
+
+ return null;
+ }
+
+ @Nullable
+ State parseState(@Nullable String stateString) {
+ // Quoted strings are parsed as StringType
+ if (stateString == null) {
+ return null;
+ } else if (stateString.startsWith("'") && stateString.endsWith("'")) {
+ return new StringType(stateString.substring(1, stateString.length() - 1));
+ } else {
+ return TypeParser.parseState(acceptedDataTypes, stateString);
+ }
+ }
+
+ class StateCondition {
+ String itemName;
+
+ ComparisonType comparisonType;
+ String value;
+
+ boolean quoted = false;
+
+ public StateCondition(String itemName, ComparisonType comparisonType, String value) {
+ this.itemName = itemName;
+ this.comparisonType = comparisonType;
+ this.value = value;
+ this.quoted = value.startsWith("'") && value.endsWith("'");
+ if (quoted) {
+ this.value = value.substring(1, value.length() - 1);
+ }
+ }
+
+ public boolean matches(String state) {
+ switch (comparisonType) {
+ case EQ:
+ return state.equals(value);
+ case NEQ: {
+ return !state.equals(value);
+ }
+ default:
+ logger.warn("Unknown condition type {}. Expected 'eq' or 'neq' - skipping state update",
+ comparisonType);
+ return false;
+
+ }
+ }
+
+ enum ComparisonType {
+ EQ,
+ NEQ
+ }
+
+ @Override
+ public String toString() {
+ return "Condition{itemName='" + itemName + "', comparisonType=" + comparisonType + ", value='" + value
+ + "'}'";
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.THRESHOLD_UID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.types.OnOffType;
+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.core.types.Type;
+import org.openhab.transform.basicprofiles.internal.config.ThresholdStateProfileConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/***
+ * This is the default implementation for a {@link ThresholdStateProfile}}. Triggers ON/OFF behavior when being linked
+ * to a Switch item if value is below threshold (default: 10).
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class ThresholdStateProfile implements StateProfile {
+
+ private final Logger logger = LoggerFactory.getLogger(ThresholdStateProfile.class);
+
+ public static final String PARAM_THRESHOLD = "threshold";
+
+ private final ProfileCallback callback;
+ private final ThresholdStateProfileConfig config;
+
+ public ThresholdStateProfile(ProfileCallback callback, ProfileContext context) {
+ this.callback = callback;
+ this.config = context.getConfiguration().as(ThresholdStateProfileConfig.class);
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return THRESHOLD_UID;
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ // do nothing
+ }
+
+ @Override
+ public void onCommandFromHandler(Command command) {
+ final Type mappedCommand = mapValue(command);
+ logger.trace("Mapped command from '{}' to command '{}'.", command, mappedCommand);
+ callback.sendCommand((Command) mappedCommand);
+ }
+
+ @Override
+ public void onCommandFromItem(Command command) {
+ // do nothing
+ }
+
+ @Override
+ public void onStateUpdateFromHandler(State state) {
+ final Type mappedState = mapValue(state);
+ logger.trace("Mapped state from '{}' to state '{}'.", state, mappedState);
+ callback.sendUpdate((State) mappedState);
+ }
+
+ private Type mapValue(Type value) {
+ if (value instanceof Number) {
+ return ((Number) value).intValue() <= config.threshold ? OnOffType.ON : OnOffType.OFF;
+ }
+ return OnOffType.OFF;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.TIME_RANGE_COMMAND_UID;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+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.TimeRangeCommandProfileConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is an enhanced implementation of a follow profile which converts {@link OnOffType} to a {@link PercentType}.
+ * The value of the percent type can be different between a specific time of the day.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class TimeRangeCommandProfile implements StateProfile {
+
+ private static final Pattern HHMM_PATTERN = Pattern.compile("^([0-1][0-9]|2[0-3])(:[0-5][0-9])$");
+ private static final String TIME_SEPARATOR = ":";
+
+ private static final String PARAM_IN_RANGE_VALUE = "inRangeValue";
+ private static final String PARAM_OUT_OF_RANGE_VALUE = "outOfRangeValue";
+ public static final String PARAM_START = "start";
+ public static final String PARAM_END = "end";
+
+ public static final String CONFIG_RESTORE_VALUE_OFF = "OFF";
+ private static final String CONFIG_RESTORE_VALUE_PREVIOUS = "PREVIOUS";
+ private static final String CONFIG_RESTORE_VALUE_NOTHING = "NOTHING";
+
+ private final Logger logger = LoggerFactory.getLogger(TimeRangeCommandProfile.class);
+ private final ProfileCallback callback;
+ private final TimeZoneProvider timeZoneProvider;
+
+ private final PercentType inRangeValue;
+ private final PercentType outOfRangeValue;
+ private final long startTimeInMinutes;
+ private final long endTimeInMinutes;
+ private final String restoreValue;
+
+ private @Nullable PercentType previousState;
+ private @Nullable PercentType restoreState;
+
+ public TimeRangeCommandProfile(ProfileCallback callback, ProfileContext context,
+ TimeZoneProvider timeZoneProvider) {
+ this.callback = callback;
+ this.timeZoneProvider = timeZoneProvider;
+
+ TimeRangeCommandProfileConfig config = context.getConfiguration().as(TimeRangeCommandProfileConfig.class);
+ logger.debug(
+ "Configuring profile with parameters: [{inRangeValue='{}', outOfRangeValue='{}', start='{}', end='{}', restoreValue='{}']",
+ config.inRangeValue, config.outOfRangeValue, config.start, config.end, config.restoreValue);
+
+ inRangeValue = parsePercentTypeConfigValue(config.inRangeValue, PARAM_IN_RANGE_VALUE);
+ outOfRangeValue = parsePercentTypeConfigValue(config.outOfRangeValue, PARAM_OUT_OF_RANGE_VALUE);
+
+ startTimeInMinutes = parseTimeConfigValue(config.start, PARAM_START);
+ endTimeInMinutes = parseTimeConfigValue(config.end, PARAM_END);
+ // We have to do this here, otherwise the comparison in getOnValue() does not work. A range beyond midnight
+ // (e.g. start="23:00", end="01:00") is not yet supported.
+ if (endTimeInMinutes <= startTimeInMinutes) {
+ logger.warn("Parameter '{}' ({}) is earlier than or equal to parameter '{}' ({}).", PARAM_END, config.end,
+ PARAM_START, config.start);
+ throw new IllegalArgumentException("Invalid time range parameter");
+ }
+
+ restoreValue = config.restoreValue;
+ }
+
+ private PercentType parsePercentTypeConfigValue(int value, String field) {
+ try {
+ return new PercentType(value);
+ } catch (IllegalArgumentException e) {
+ logger.warn("Cannot parse profile configuration '{}' to percent, use a value between 0 and 100!", field);
+ throw new IllegalArgumentException("Invalid profile configuration '" + field + "'", e);
+ }
+ }
+
+ private long parseTimeConfigValue(String value, String field) {
+ try {
+ return getMinutesFromTime(value);
+ } catch (PatternSyntaxException | NumberFormatException | ArithmeticException e) {
+ logger.warn("Cannot parse profile configuration '{}' to hour and minutes, use pattern hh:mm!", field);
+ throw new IllegalArgumentException("Invalid profile configuration '" + field + "'", e);
+ }
+ }
+
+ /**
+ * Parses a hh:mm string and returns the minutes.
+ */
+ private long getMinutesFromTime(String configTime)
+ throws PatternSyntaxException, NumberFormatException, ArithmeticException {
+ String time = configTime;
+ if (!(time = time.trim()).isBlank()) {
+ if (!HHMM_PATTERN.matcher(time).matches()) {
+ throw new NumberFormatException();
+ } else {
+ String[] splittedConfigTime = time.split(TIME_SEPARATOR);
+ if (splittedConfigTime.length < 2) {
+ throw new NumberFormatException();
+ }
+ int hour = Integer.parseInt(splittedConfigTime[0]);
+ int minutes = Integer.parseInt(splittedConfigTime[1]);
+ return Duration.ofMinutes(minutes).plusHours(hour).toMinutes();
+ }
+ }
+ return 0;
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return TIME_RANGE_COMMAND_UID;
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ if (!(state instanceof OnOffType)) {
+ logger.debug("The given state '{}' cannot be transformed to an OnOffType.", state);
+ return;
+ }
+
+ PercentType newCommand = OnOffType.OFF.equals(state) ? getOffValue() : getOnValue();
+ if (newCommand != null) {
+ logger.debug("Forward new command '{}' to the respective thing handler.", newCommand);
+ if (CONFIG_RESTORE_VALUE_PREVIOUS.equals(restoreValue)) {
+ logger.debug("'restoreValue' ({}): Remember previous state '{}'.", restoreValue, previousState);
+ restoreState = previousState;
+ }
+ callback.handleCommand(newCommand);
+ }
+ }
+
+ @Override
+ public void onCommandFromHandler(Command command) {
+ if (!(command instanceof OnOffType)) {
+ logger.debug("The given command '{}' cannot be handled. It is not an OnOffType.", command);
+ return;
+ }
+
+ PercentType newCommand = OnOffType.OFF.equals(command) ? getOffValue() : getOnValue();
+ if (newCommand != null) {
+ logger.debug("Send new command '{}' to the framework.", newCommand);
+ if (CONFIG_RESTORE_VALUE_PREVIOUS.equals(restoreValue)) {
+ logger.debug("'restoreValue' ({}): Remember previous state '{}'.", restoreValue, previousState);
+ restoreState = previousState;
+ }
+ callback.sendCommand(newCommand);
+ }
+ }
+
+ private @Nullable PercentType getOffValue() {
+ switch (restoreValue) {
+ case CONFIG_RESTORE_VALUE_OFF:
+ return PercentType.ZERO;
+ case CONFIG_RESTORE_VALUE_NOTHING:
+ logger.debug("'restoreValue' ({}): Do nothing.", restoreValue);
+ return null;
+ case CONFIG_RESTORE_VALUE_PREVIOUS:
+ return restoreState;
+ default:
+ // try to transform config parameter 'restoreValue' to a valid PercentType
+ try {
+ return PercentType.valueOf(restoreValue);
+ } catch (IllegalArgumentException e) {
+ logger.warn("The given parameter 'restoreValue' ({}) cannot be transformed to a valid PercentType.",
+ restoreValue);
+ return null;
+ }
+ }
+ }
+
+ private PercentType getOnValue() {
+ ZonedDateTime now = Instant.now().atZone(timeZoneProvider.getTimeZone());
+ ZonedDateTime today = now.truncatedTo(ChronoUnit.DAYS);
+ return now.isAfter(today.plusMinutes(startTimeInMinutes)) && now.isBefore(today.plusMinutes(endTimeInMinutes))
+ ? inRangeValue
+ : outOfRangeValue;
+ }
+
+ @Override
+ public void onCommandFromItem(Command command) {
+ // no-op
+ }
+
+ @Override
+ public void onStateUpdateFromHandler(State state) {
+ PercentType pState = state.as(PercentType.class);
+ if (pState != null) {
+ logger.debug("Item state changed, set 'previousState' to '{}'.", pState);
+ previousState = pState;
+ }
+ }
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="basicprofiles" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+ <type>transformation</type>
+ <name>Basic Profiles</name>
+ <description>A set of profiles with basic functionality.</description>
+ <connection>local</connection>
+
+</addon:addon>
--- /dev/null
+<?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-counting">
+ <parameter name="numberOfChanges" type="integer" min="0" step="1">
+ <label>Number Of Changes</label>
+ <description>Number of changes before updating Item State.</description>
+ <default>1</default>
+ </parameter>
+ </config-description>
+
+ <config-description uri="profile:basic-profiles:debounce-time">
+ <parameter name="toItemDelay" type="integer" min="0" step="1" unit="ms">
+ <label>To Item Delay</label>
+ <description>Timespan before an value is forwarded to the item (or discarded after the first value).</description>
+ <default>0</default>
+ </parameter>
+ <parameter name="toHandlerDelay" type="integer" min="0" step="1" unit="ms">
+ <label>To Handler Delay</label>
+ <description>Timespan before an value is forwarded to the handler (or discarded after the first value).</description>
+ <default>0</default>
+ </parameter>
+ <parameter name="mode" type="text">
+ <label>Mode</label>
+ <options>
+ <option value="FIRST">Send first value</option>
+ <option value="LAST">Send last value</option>
+ </options>
+ <default>LAST</default>
+ <limitToOptions>true</limitToOptions>
+ </parameter>
+ </config-description>
+</config-description:config-descriptions>
--- /dev/null
+<?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:generic-command">
+ <parameter name="events" type="text" required="true" multiple="true">
+ <label>Events</label>
+ <description>Comma separated list of events to which the profile should listen.</description>
+ </parameter>
+ <parameter name="command" type="text" required="true">
+ <label>Command</label>
+ <description>Command which should be sent if the event is triggered.</description>
+ <limitToOptions>false</limitToOptions>
+ <options>
+ <option value="INCREASE">INCREASE</option>
+ <option value="DECREASE">DECREASE</option>
+ <option value="NEXT">NEXT</option>
+ <option value="PREVIOUS">PREVIOUS</option>
+ <option value="ON">ON</option>
+ <option value="OFF">OFF</option>
+ <option value="PLAY">PLAY</option>
+ <option value="PAUSE">PAUSE</option>
+ <option value="REWIND">REWIND</option>
+ <option value="FASTFORWARD">FASTFORWARD</option>
+ <option value="STOP">STOP</option>
+ <option value="MOVE">MOVE</option>
+ <option value="UP">UP</option>
+ <option value="DOWN">DOWN</option>
+ </options>
+ </parameter>
+ </config-description>
+</config-description:config-descriptions>
--- /dev/null
+<?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:round">
+ <parameter name="scale" type="integer" min="-16" max="16" step="1" required="true">
+ <label>Scale</label>
+ <description>Scale to indicate the resulting number of decimal places.</description>
+ </parameter>
+ <parameter name="mode" type="text">
+ <label>Rounding Mode</label>
+ <description>Rounding mode to be used (e.g. "UP", "DOWN", "CEILING", "FLOOR", "HALF_UP" or "HALF_DOWN").
+ </description>
+ <default>HALF_UP</default>
+ <options>
+ <option value="UP">UP</option>
+ <option value="DOWN">DOWN</option>
+ <option value="CEILING">CEILING</option>
+ <option value="FLOOR">FLOOR</option>
+ <option value="HALF_UP">HALF_UP</option>
+ <option value="HALF_DOWN">HALF_DOWN</option>
+ </options>
+ </parameter>
+ </config-description>
+</config-description:config-descriptions>
--- /dev/null
+<?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:state-filter">
+ <parameter name="conditions" type="text" required="true">
+ <label>Conditions</label>
+ <description>Comma separated list of expressions on the format ITEM_NAME OPERATOR ITEM_STATE, ie "MyItem EQ OFF". Use
+ quotes around ITEM_STATE to treat value as string ie "'OFF'".</description>
+ </parameter>
+ <parameter name="mismatchState" type="text">
+ <label>State for filter rejects</label>
+ <description>State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType`</description>
+ </parameter>
+ </config-description>
+</config-description:config-descriptions>
--- /dev/null
+<?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:threshold">
+ <parameter name="threshold" type="integer" min="0" max="100" step="1">
+ <label>Threshold</label>
+ <description>Triggers ON if value is below the given threshold, otherwise OFF.</description>
+ <default>10</default>
+ </parameter>
+ </config-description>
+</config-description:config-descriptions>
--- /dev/null
+<?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:time-range-command">
+ <parameter name="inRangeValue" type="integer" min="0" max="100" step="1">
+ <label>In Range Value</label>
+ <description>The value which will be send when the profile detects ON and current time is between start time and end
+ time.</description>
+ <default>100</default>
+ </parameter>
+ <parameter name="outOfRangeValue" type="integer" min="0" max="100" step="1">
+ <label>Out Of Range Value</label>
+ <description>The value which will be send when the profile detects ON and current time is NOT between start time and
+ end time.</description>
+ <default>30</default>
+ </parameter>
+ <parameter name="start" type="text" pattern="^([0-1][0-9]|2[0-3])(:[0-5][0-9])$" required="true">
+ <label>Start Time</label>
+ <description>The start time of the day (hh:mm).</description>
+ <context>time</context>
+ </parameter>
+ <parameter name="end" type="text" pattern="^([0-1][0-9]|2[0-3])(:[0-5][0-9])$" required="true">
+ <label>End Time</label>
+ <description>The end time of the day (hh:mm).</description>
+ <context>time</context>
+ </parameter>
+ <parameter name="restoreValue" type="text">
+ <advanced>true</advanced>
+ <label>Restore Value</label>
+ <description>Select what should happen when the profile detects OFF again.</description>
+ <options>
+ <option value="OFF">Off</option>
+ <option value="PREVIOUS">Return to previous value</option>
+ <option value="NOTHING">Do nothing</option>
+ </options>
+ <limitToOptions>false</limitToOptions>
+ <default>OFF</default>
+ </parameter>
+ </config-description>
+</config-description:config-descriptions>
--- /dev/null
+<?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:toggle-switch">
+ <parameter name="events" type="text" required="true" multiple="true">
+ <label>Events</label>
+ <description>Comma separated list of events to which the profile should listen.</description>
+ </parameter>
+ </config-description>
+</config-description:config-descriptions>
--- /dev/null
+profile-type.basic-profiles.generic-command.label = Generic Command
+profile.config.basic-profiles.generic-command.events.label = Events
+profile.config.basic-profiles.generic-command.events.description = Comma separated list of events to which the profile should listen.
+profile.config.basic-profiles.generic-command.command.label = Command
+profile.config.basic-profiles.generic-command.command.description = Command which should be sent if the event is triggered.
+
+profile-type.basic-profiles.toggle-switch.label = Generic Toggle Switch
+profile.config.basic-profiles.toggle-switch.events.label = Events
+profile.config.basic-profiles.toggle-switch.events.description = Comma separated list of events to which the profile should listen.
+
+profile-type.basic-profiles.debounce-counting.label = Debounce (Counting)
+profile.config.basic-profiles.debounce-counting.numberOfChanges.label = Number Of Changes
+profile.config.basic-profiles.debounce-counting.numberOfChanges.description = Number of changes before updating Item State.
+
+profile-type.basic-profiles.debounce-time.label = Debounce (Time)
+profile.config.basic-profiles.debounce-time.toItemDelay.label = To Item Delay
+profile.config.basic-profiles.debounce-time.toItemDelay.description = Milliseconds before updating Item State.
+profile.config.basic-profiles.debounce-time.toHandlerDelay.label = To Handler Delay
+profile.config.basic-profiles.debounce-time.toHandlerDelay.description = Milliseconds before sending to thing handler.
+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
+
+profile-type.basic-profiles.invert.label = Invert / Negate
+
+profile-type.basic-profiles.round.label = Round
+profile.config.basic-profiles.round.scale.label = Scale
+profile.config.basic-profiles.round.scale.description = Scale to indicate the resulting number of decimal places.
+profile.config.basic-profiles.round.mode.label = Rounding Mode
+profile.config.basic-profiles.round.mode.description = Rounding mode to be used (UP, DOWN, CEILING, FLOOR, HALF_UP or HALF_DOWN).
+
+profile-type.basic-profiles.threshold.label = Threshold
+profile.config.basic-profiles.threshold.threshold.label = Threshold
+profile.config.basic-profiles.threshold.threshold.description = Triggers ON if value is below the given threshold, otherwise OFF.
+
+profile-type.basic-profiles.time-range-command.label = Time Range Command
+profile.config.basic-profiles.time-range-command.inRangeValue.label = In Range Value
+profile.config.basic-profiles.time-range-command.inRangeValue.description = The value which will be send when the profile detects ON and current time is between start time and end time.
+profile.config.basic-profiles.time-range-command.outOfRangeValue.label = Out Of Range Value
+profile.config.basic-profiles.time-range-command.outOfRangeValue.description = The value which will be send when the profile detects ON and current time is NOT between start time and end time.
+profile.config.basic-profiles.time-range-command.start.label = Start Time
+profile.config.basic-profiles.time-range-command.start.description = The start time of the day (hh:mm).
+profile.config.basic-profiles.time-range-command.end.label = End Time
+profile.config.basic-profiles.time-range-command.end.description = The end time of the day (hh:mm).
+profile.config.basic-profiles.time-range-command.restoreValue.label = Restore Value
+profile.config.basic-profiles.time-range-command.restoreValue.description = Select what should happen when the profile detects OFF again.
+profile.config.basic-profiles.time-range-command.restoreValue.option.OFF = Off
+profile.config.basic-profiles.time-range-command.restoreValue.option.PREVIOUS = Return to previous value
+profile.config.basic-profiles.time-range-command.restoreValue.option.NOTHING = Do nothing
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.factory;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+import java.util.Collection;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.ProfileType;
+import org.openhab.core.thing.profiles.ProfileTypeUID;
+import org.openhab.core.thing.profiles.i18n.ProfileTypeI18nLocalizationService;
+import org.openhab.core.util.BundleResolver;
+import org.openhab.transform.basicprofiles.internal.profiles.GenericCommandTriggerProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.RoundStateProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.ThresholdStateProfile;
+import org.openhab.transform.basicprofiles.internal.profiles.TimeRangeCommandProfile;
+
+/**
+ * Basic unit tests for {@link BasicProfilesFactory}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class BasicProfilesFactoryTest {
+
+ private static final int NUMBER_OF_PROFILES = 9;
+
+ private static final Map<String, Object> PROPERTIES = Map.of(ThresholdStateProfile.PARAM_THRESHOLD, 15,
+ RoundStateProfile.PARAM_SCALE, 2, GenericCommandTriggerProfile.PARAM_EVENTS, "1002,1003",
+ GenericCommandTriggerProfile.PARAM_COMMAND, OnOffType.ON.toString(), TimeRangeCommandProfile.PARAM_START,
+ "08:00", TimeRangeCommandProfile.PARAM_END, "23:00");
+ private static final Configuration CONFIG = new Configuration(PROPERTIES);
+
+ private @Mock @NonNullByDefault({}) ProfileTypeI18nLocalizationService mockLocalizationService;
+ private @Mock @NonNullByDefault({}) TimeZoneProvider mockTimeZoneProvider;
+ private @Mock @NonNullByDefault({}) BundleResolver mockBundleResolver;
+ private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+ private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+ private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry;
+
+ private @NonNullByDefault({}) BasicProfilesFactory profileFactory;
+
+ @BeforeEach
+ public void setup() {
+ profileFactory = new BasicProfilesFactory(mockLocalizationService, mockBundleResolver, mockItemRegistry,
+ mockTimeZoneProvider);
+
+ when(mockContext.getConfiguration()).thenReturn(CONFIG);
+ }
+
+ @Test
+ public void systemProfileTypesAndUidsShouldBeAvailable() {
+ Collection<ProfileTypeUID> supportedProfileTypeUIDs = profileFactory.getSupportedProfileTypeUIDs();
+ assertThat(supportedProfileTypeUIDs, hasSize(NUMBER_OF_PROFILES));
+
+ Collection<ProfileType> supportedProfileTypes = profileFactory.getProfileTypes(null);
+ assertThat(supportedProfileTypeUIDs, hasSize(NUMBER_OF_PROFILES));
+
+ for (ProfileType profileType : supportedProfileTypes) {
+ assertTrue(supportedProfileTypeUIDs.contains(profileType.getUID()));
+ }
+ }
+
+ @Test
+ public void testFactoryCreatesAvailableProfiles() {
+ for (ProfileTypeUID profileTypeUID : profileFactory.getSupportedProfileTypeUIDs()) {
+ assertNotNull(profileFactory.createProfile(profileTypeUID, mockCallback, mockContext));
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.eq;
+import static org.mockito.Mockito.*;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+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.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.StateProfile;
+import org.openhab.core.types.State;
+
+/**
+ * Debounces a {@link State} by counting.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@NonNullByDefault
+class DebounceCountingStateProfileTest {
+
+ public static class ParameterSet {
+ public final List<State> sourceStates;
+ public final List<State> resultingStates;
+ public final int numberOfChanges;
+
+ public ParameterSet(List<State> sourceStates, List<State> resultingStates, int numberOfChanges) {
+ this.sourceStates = sourceStates;
+ this.resultingStates = resultingStates;
+ this.numberOfChanges = numberOfChanges;
+ }
+ }
+
+ public static Collection<Object[]> parameters() {
+ return Arrays.asList(new Object[][] { //
+ { new ParameterSet(List.of(OnOffType.ON), List.of(OnOffType.ON), 0) }, //
+ { new ParameterSet(List.of(OnOffType.ON), List.of(OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON), List.of(OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF), List.of(OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.OFF), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.OFF), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.OFF), 1) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF, OnOffType.ON),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+ { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF, OnOffType.OFF),
+ List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.OFF), 2) } //
+ });
+ }
+
+ private @NonNullByDefault({}) @Mock ProfileCallback mockCallback;
+ private @NonNullByDefault({}) @Mock ProfileContext mockContext;
+
+ @Test
+ public void testWrongParameterLower() {
+ assertThrows(IllegalArgumentException.class, () -> initProfile(-1));
+ }
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void testOnStateUpdateFromHandler(ParameterSet parameterSet) {
+ final StateProfile profile = initProfile(parameterSet.numberOfChanges);
+ for (int i = 0; i < parameterSet.sourceStates.size(); i++) {
+ verifySendUpdate(profile, parameterSet.sourceStates.get(i), parameterSet.resultingStates.get(i));
+ }
+ }
+
+ private StateProfile initProfile(int numberOfChanges) {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("numberOfChanges", numberOfChanges)));
+ return new DebounceCountingStateProfile(mockCallback, mockContext);
+ }
+
+ private void verifySendUpdate(StateProfile profile, State state, State expectedState) {
+ reset(mockCallback);
+ profile.onStateUpdateFromHandler(state);
+ verify(mockCallback, times(1)).sendUpdate(eq(expectedState));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.StopMoveType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
+import org.openhab.core.thing.CommonTriggerEvents;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.TriggerProfile;
+import org.openhab.core.types.Command;
+
+/**
+ * Basic unit tests for {@link GenericCommandTriggerProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class GenericCommandTriggerProfileTest {
+
+ public static class ParameterSet {
+ public final String events;
+ public final Command command;
+
+ public ParameterSet(String events, Command command) {
+ this.events = events;
+ this.command = command;
+ }
+ }
+
+ public static Collection<Object[]> parameters() {
+ return List.of(new Object[][] { //
+ { new ParameterSet("1002", OnOffType.ON) }, //
+ { new ParameterSet("1002", OnOffType.OFF) }, //
+ { new ParameterSet("1002,1003", PlayPauseType.PLAY) }, //
+ { new ParameterSet("1002,1003", PlayPauseType.PAUSE) }, //
+ { new ParameterSet("1002,1003,3001", StopMoveType.STOP) }, //
+ { new ParameterSet("1002,1003,3001", StopMoveType.MOVE) }, //
+ { new ParameterSet(CommonTriggerEvents.LONG_PRESSED + "," + CommonTriggerEvents.SHORT_PRESSED,
+ UpDownType.UP) }, //
+ { new ParameterSet(CommonTriggerEvents.LONG_PRESSED + "," + CommonTriggerEvents.SHORT_PRESSED,
+ UpDownType.DOWN) }, //
+ { new ParameterSet("1003", StringType.valueOf("SELECT")) } //
+ });
+ }
+
+ private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+ private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void testOnOffSwitchItem(ParameterSet parameterSet) {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of(AbstractTriggerProfile.PARAM_EVENTS,
+ parameterSet.events, GenericCommandTriggerProfile.PARAM_COMMAND, parameterSet.command.toFullString())));
+
+ TriggerProfile profile = new GenericCommandTriggerProfile(mockCallback, mockContext);
+
+ verifyNoAction(profile, CommonTriggerEvents.PRESSED, parameterSet.command);
+ for (String event : parameterSet.events.split(",")) {
+ verifyAction(profile, event, parameterSet.command);
+ }
+ }
+
+ private void verifyAction(TriggerProfile profile, String trigger, Command expectation) {
+ reset(mockCallback);
+ profile.onTriggerFromHandler(trigger);
+ verify(mockCallback, times(1)).sendCommand(eq(expectation));
+ }
+
+ private void verifyNoAction(TriggerProfile profile, String trigger, Command expectation) {
+ reset(mockCallback);
+ profile.onTriggerFromHandler(trigger);
+ verify(mockCallback, times(0)).sendCommand(eq(expectation));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.thing.CommonTriggerEvents;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.TriggerProfile;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Basic unit tests for {@link GenericToggleSwitchTriggerProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class GenericToggleSwitchTriggerProfileTest {
+
+ private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+ private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+
+ @BeforeEach
+ public void setup() {
+ when(mockContext.getConfiguration()).thenReturn(
+ new Configuration(Map.of(AbstractTriggerProfile.PARAM_EVENTS, CommonTriggerEvents.PRESSED)));
+ }
+
+ @Test
+ public void testSwitchItem() {
+ TriggerProfile profile = new GenericToggleSwitchTriggerProfile(mockCallback, mockContext);
+
+ verifyAction(profile, UnDefType.NULL, OnOffType.ON);
+ verifyAction(profile, OnOffType.ON, OnOffType.OFF);
+ verifyAction(profile, OnOffType.OFF, OnOffType.ON);
+ }
+
+ @Test
+ public void testDimmerItem() {
+ TriggerProfile profile = new GenericToggleSwitchTriggerProfile(mockCallback, mockContext);
+
+ verifyAction(profile, UnDefType.NULL, OnOffType.ON);
+ verifyAction(profile, PercentType.HUNDRED, OnOffType.OFF);
+ verifyAction(profile, PercentType.ZERO, OnOffType.ON);
+ verifyAction(profile, new PercentType(50), OnOffType.OFF);
+ }
+
+ @Test
+ public void testColorItem() {
+ TriggerProfile profile = new GenericToggleSwitchTriggerProfile(mockCallback, mockContext);
+
+ verifyAction(profile, UnDefType.NULL, OnOffType.ON);
+ verifyAction(profile, HSBType.WHITE, OnOffType.OFF);
+ verifyAction(profile, HSBType.BLACK, OnOffType.ON);
+ verifyAction(profile, new HSBType("0,50,50"), OnOffType.OFF);
+ }
+
+ private void verifyAction(TriggerProfile profile, State preCondition, Command expectation) {
+ reset(mockCallback);
+ profile.onStateUpdateFromItem(preCondition);
+ profile.onTriggerFromHandler(CommonTriggerEvents.PRESSED);
+ verify(mockCallback, times(1)).sendCommand(eq(expectation));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.StateProfile;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.Type;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Basic unit tests for {@link InvertStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class InvertStateProfileTest {
+
+ private static final DateTimeType NOW = new DateTimeType();
+
+ public static class ParameterSet {
+ public Type type;
+ public Type resultingType;
+
+ public ParameterSet(Type source, Type result) {
+ this.type = source;
+ this.resultingType = result;
+ }
+ }
+
+ public static Collection<Object[]> parameters() {
+ return List.of(new Object[][] { //
+ { new ParameterSet(UnDefType.NULL, UnDefType.NULL) }, //
+ { new ParameterSet(UnDefType.UNDEF, UnDefType.UNDEF) }, //
+ { new ParameterSet(new QuantityType<>(25, Units.LITRE), new QuantityType<>(-25, Units.LITRE)) }, //
+ { new ParameterSet(PercentType.ZERO, PercentType.HUNDRED) }, //
+ { new ParameterSet(PercentType.HUNDRED, PercentType.ZERO) }, //
+ { new ParameterSet(new PercentType(25), new PercentType(75)) }, //
+ { new ParameterSet(new DecimalType(25L), new DecimalType(-25L)) }, //
+ { new ParameterSet(OnOffType.ON, OnOffType.OFF) }, //
+ { new ParameterSet(OnOffType.OFF, OnOffType.ON) }, //
+ { new ParameterSet(NOW, NOW) } //
+ });
+ }
+
+ private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void testOnCommandFromHandler(ParameterSet parameterSet) {
+ final StateProfile profile = initProfile();
+ if (parameterSet.type instanceof Command && parameterSet.resultingType instanceof Command) {
+ verifyCommandFromItem(profile, (Command) parameterSet.type, (Command) parameterSet.resultingType);
+ verifyCommandFromHandler(profile, (Command) parameterSet.type, (Command) parameterSet.resultingType);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void testOnStateUpdateFromHandler(ParameterSet parameterSet) {
+ final StateProfile profile = initProfile();
+ if (parameterSet.type instanceof State && parameterSet.resultingType instanceof State) {
+ verifyStateUpdateFromHandler(profile, (State) parameterSet.type, (State) parameterSet.resultingType);
+ }
+ }
+
+ private StateProfile initProfile() {
+ return new InvertStateProfile(mockCallback);
+ }
+
+ private void verifyCommandFromItem(StateProfile profile, Command command, Command result) {
+ reset(mockCallback);
+ profile.onCommandFromItem(command);
+ verify(mockCallback, times(1)).handleCommand(eq(result));
+ }
+
+ private void verifyCommandFromHandler(StateProfile profile, Command command, Command result) {
+ reset(mockCallback);
+ profile.onCommandFromHandler(command);
+ verify(mockCallback, times(1)).sendCommand(eq(result));
+ }
+
+ private void verifyStateUpdateFromHandler(StateProfile profile, State state, State result) {
+ reset(mockCallback);
+ profile.onStateUpdateFromHandler(state);
+ verify(mockCallback, times(1)).sendUpdate(eq(result));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.Mockito.*;
+
+import java.math.RoundingMode;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.ImperialUnits;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.types.Command;
+
+/**
+ * Basic unit tests for {@link RoundStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class RoundStateProfileTest {
+
+ @BeforeEach
+ public void setup() {
+ // initialize parser with ImperialUnits, otherwise units like °F are unknown
+ @SuppressWarnings("unused")
+ Unit<Temperature> fahrenheit = ImperialUnits.FAHRENHEIT;
+ }
+
+ @Test
+ public void testParsingParameters() {
+ ProfileCallback callback = mock(ProfileCallback.class);
+ RoundStateProfile roundProfile = createProfile(callback, 2, "NOT_SUPPORTED");
+
+ assertThat(roundProfile.scale, is(2));
+ assertThat(roundProfile.roundingMode, is(RoundingMode.HALF_UP));
+ }
+
+ @Test
+ public void testDecimalTypeOnCommandFromItem() {
+ ProfileCallback callback = mock(ProfileCallback.class);
+ RoundStateProfile roundProfile = createProfile(callback, 2);
+
+ Command cmd = new DecimalType(23.333);
+ roundProfile.onCommandFromItem(cmd);
+
+ ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+ verify(callback, times(1)).handleCommand(capture.capture());
+
+ Command result = capture.getValue();
+ DecimalType dtResult = (DecimalType) result;
+ assertThat(dtResult.doubleValue(), is(23.33));
+ }
+
+ @Test
+ public void testDecimalTypeOnCommandFromItemWithNegativeScale() {
+ ProfileCallback callback = mock(ProfileCallback.class);
+ RoundStateProfile roundProfile = createProfile(callback, -2);
+
+ Command cmd = new DecimalType(1234.333);
+ roundProfile.onCommandFromItem(cmd);
+
+ ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+ verify(callback, times(1)).handleCommand(capture.capture());
+
+ Command result = capture.getValue();
+ DecimalType dtResult = (DecimalType) result;
+ assertThat(dtResult.doubleValue(), is(1200.0));
+ }
+
+ @Test
+ public void testDecimalTypeOnCommandFromItemWithCeiling() {
+ ProfileCallback callback = mock(ProfileCallback.class);
+ RoundStateProfile roundProfile = createProfile(callback, 0, RoundingMode.CEILING.name());
+
+ Command cmd = new DecimalType(23.3);
+ roundProfile.onCommandFromItem(cmd);
+
+ ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+ verify(callback, times(1)).handleCommand(capture.capture());
+
+ Command result = capture.getValue();
+ DecimalType dtResult = (DecimalType) result;
+ assertThat(dtResult.doubleValue(), is(24.0));
+ }
+
+ @Test
+ public void testDecimalTypeOnCommandFromItemWithFloor() {
+ ProfileCallback callback = mock(ProfileCallback.class);
+ RoundStateProfile roundProfile = createProfile(callback, 0, RoundingMode.FLOOR.name());
+
+ Command cmd = new DecimalType(23.6);
+ roundProfile.onCommandFromItem(cmd);
+
+ ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+ verify(callback, times(1)).handleCommand(capture.capture());
+
+ Command result = capture.getValue();
+ DecimalType dtResult = (DecimalType) result;
+ assertThat(dtResult.doubleValue(), is(23.0));
+ }
+
+ @Test
+ public void testQuantityTypeOnCommandFromItem() {
+ ProfileCallback callback = mock(ProfileCallback.class);
+ RoundStateProfile roundProfile = createProfile(callback, 1);
+
+ Command cmd = new QuantityType<Temperature>("23.333 °C");
+ roundProfile.onCommandFromItem(cmd);
+
+ ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+ verify(callback, times(1)).handleCommand(capture.capture());
+
+ Command result = capture.getValue();
+ @SuppressWarnings("unchecked")
+ QuantityType<Temperature> qtResult = (QuantityType<Temperature>) result;
+ assertThat(qtResult.doubleValue(), is(23.3));
+ assertThat(qtResult.getUnit(), is(SIUnits.CELSIUS));
+ }
+
+ private RoundStateProfile createProfile(ProfileCallback callback, Integer scale) {
+ return createProfile(callback, scale, null);
+ }
+
+ private RoundStateProfile createProfile(ProfileCallback callback, Integer scale, @Nullable String mode) {
+ ProfileContext context = mock(ProfileContext.class);
+ Configuration config = new Configuration();
+ config.put(RoundStateProfile.PARAM_SCALE, scale);
+ config.put(RoundStateProfile.PARAM_MODE, mode == null ? RoundingMode.HALF_UP.name() : mode);
+ when(context.getConfiguration()).thenReturn(config);
+
+ return new RoundStateProfile(callback, context);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.items.Item;
+import org.openhab.core.items.ItemNotFoundException;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.library.items.StringItem;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Basic unit tests for {@link StateFilterProfile}.
+ *
+ * @author Arne Seime - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class StateFilterProfileTest {
+
+ private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+ private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+ private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry;
+
+ @BeforeEach
+ public void setup() {
+ reset(mockContext);
+ reset(mockCallback);
+ }
+
+ @Test
+ public void testNoConditions() {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "")));
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ State expectation = OnOffType.ON;
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+ }
+
+ @Test
+ public void testMalformedConditions() {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName invalid")));
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ State expectation = OnOffType.ON;
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+ }
+
+ @Test
+ public void testInvalidComparatorConditions() throws ItemNotFoundException {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName lt Value")));
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+ when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class);
+
+ State expectation = OnOffType.ON;
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+ }
+
+ @Test
+ public void testInvalidItemConditions() throws ItemNotFoundException {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value")));
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class);
+ State expectation = OnOffType.ON;
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+ }
+
+ @Test
+ public void testInvalidMultipleConditions() throws ItemNotFoundException {
+ when(mockContext.getConfiguration())
+ .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value,itemname invalid")));
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+ when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class);
+
+ State expectation = OnOffType.ON;
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+ }
+
+ @Test
+ public void testSingleConditionMatch() throws ItemNotFoundException {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value")));
+ when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ State expectation = new StringType("NewValue");
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(1)).sendUpdate(eq(expectation));
+ }
+
+ @Test
+ public void testSingleConditionMatchQuoted() throws ItemNotFoundException {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq 'Value'")));
+ when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ State expectation = new StringType("NewValue");
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(1)).sendUpdate(eq(expectation));
+ }
+
+ private Item stringItemWithState(String itemName, String value) {
+ StringItem item = new StringItem(itemName);
+ item.setState(new StringType(value));
+ return item;
+ }
+
+ @Test
+ public void testMultipleCondition_AllMatch() throws ItemNotFoundException {
+ when(mockContext.getConfiguration())
+ .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value, ItemName2 eq Value2")));
+ when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
+ when(mockItemRegistry.getItem("ItemName2")).thenReturn(stringItemWithState("ItemName2", "Value2"));
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ State expectation = new StringType("NewValue");
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(1)).sendUpdate(eq(expectation));
+ }
+
+ @Test
+ public void testMultipleCondition_SingleMatch() throws ItemNotFoundException {
+ when(mockContext.getConfiguration())
+ .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value, ItemName2 eq Value2")));
+ when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
+ when(mockItemRegistry.getItem("ItemName2")).thenThrow(ItemNotFoundException.class);
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ State expectation = new StringType("NewValue");
+ profile.onStateUpdateFromHandler(expectation);
+ verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+ }
+
+ @Test
+ public void testFailingConditionWithMismatchState() throws ItemNotFoundException {
+ when(mockContext.getConfiguration())
+ .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value", "mismatchState", "UNDEF")));
+ when(mockContext.getAcceptedDataTypes()).thenReturn(List.of(UnDefType.class, StringType.class));
+ when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Mismatch"));
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ profile.onStateUpdateFromHandler(new StringType("ToBeDiscarded"));
+ verify(mockCallback, times(1)).sendUpdate(eq(UnDefType.UNDEF));
+ }
+
+ @Test
+ public void testFailingConditionWithMismatchStateQuoted() throws ItemNotFoundException {
+ when(mockContext.getConfiguration())
+ .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value", "mismatchState", "'UNDEF'")));
+ when(mockContext.getAcceptedDataTypes()).thenReturn(List.of(UnDefType.class, StringType.class));
+ when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Mismatch"));
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ profile.onStateUpdateFromHandler(new StringType("ToBeDiscarded"));
+ verify(mockCallback, times(1)).sendUpdate(eq(new StringType("UNDEF")));
+ }
+
+ @Test
+ void testParseStateNonQuotes() {
+ when(mockContext.getAcceptedDataTypes())
+ .thenReturn(List.of(UnDefType.class, OnOffType.class, StringType.class));
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "")));
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+ assertEquals(UnDefType.UNDEF, profile.parseState("UNDEF"));
+ assertEquals(new StringType("UNDEF"), profile.parseState("'UNDEF'"));
+ assertEquals(OnOffType.ON, profile.parseState("ON"));
+ assertEquals(new StringType("ON"), profile.parseState("'ON'"));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 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.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.math.BigDecimal;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.StateProfile;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.Type;
+
+/**
+ * Basic unit tests for {@link ThresholdStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class ThresholdStateProfileTest {
+
+ public static class ParameterSet {
+ public State state;
+ public State resultingState;
+ public Command command;
+ public Command resultingCommand;
+ public int treshold;
+
+ public ParameterSet(Type source, Type result, int treshold) {
+ this.state = (State) source;
+ this.resultingState = (State) result;
+ this.command = (Command) source;
+ this.resultingCommand = (Command) result;
+ this.treshold = treshold;
+ }
+ }
+
+ public static Collection<Object[]> parameters() {
+ return List.of(new Object[][] { //
+ { new ParameterSet(PercentType.HUNDRED, OnOffType.OFF, 10) }, //
+ { new ParameterSet(new PercentType(BigDecimal.valueOf(25)), OnOffType.OFF, 10) }, //
+ { new ParameterSet(PercentType.ZERO, OnOffType.ON, 10) }, //
+ { new ParameterSet(PercentType.HUNDRED, OnOffType.OFF, 40) }, //
+ { new ParameterSet(new PercentType(BigDecimal.valueOf(25)), OnOffType.ON, 40) }, //
+ { new ParameterSet(PercentType.ZERO, OnOffType.ON, 40) } //
+ });
+ }
+
+ private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+ private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void testOnCommandFromHandler(ParameterSet parameterSet) {
+ final StateProfile profile = initProfile(parameterSet.treshold);
+ verifySendCommand(profile, parameterSet.command, parameterSet.resultingCommand);
+ }
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void testOnStateUpdateFromHandler(ParameterSet parameterSet) {
+ final StateProfile profile = initProfile(parameterSet.treshold);
+ verifySendUpdate(profile, parameterSet.state, parameterSet.resultingState);
+ }
+
+ private StateProfile initProfile(int threshold) {
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("threshold", threshold)));
+ return new ThresholdStateProfile(mockCallback, mockContext);
+ }
+
+ private void verifySendCommand(StateProfile profile, Command command, Command result) {
+ reset(mockCallback);
+ profile.onCommandFromHandler(command);
+ verify(mockCallback, times(1)).sendCommand(eq(result));
+ }
+
+ private void verifySendUpdate(StateProfile profile, State state, State result) {
+ reset(mockCallback);
+ profile.onStateUpdateFromHandler(state);
+ verify(mockCallback, times(1)).sendUpdate(eq(result));
+ }
+}
<module>org.openhab.io.neeo</module>
<module>org.openhab.io.openhabcloud</module>
<!-- transformations -->
+ <module>org.openhab.transform.basicprofiles</module>
<module>org.openhab.transform.bin2json</module>
<module>org.openhab.transform.exec</module>
<module>org.openhab.transform.jinja</module>