diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index ae6156a70c0..86fba926572 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -2111,6 +2111,11 @@
org.openhab.persistence.rrd4j${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.transform.basicprofiles
+ ${project.version}
+ org.openhab.addons.bundlesorg.openhab.transform.bin2json
diff --git a/bundles/org.openhab.transform.basicprofiles/NOTICE b/bundles/org.openhab.transform.basicprofiles/NOTICE
new file mode 100644
index 00000000000..2e65467d8a9
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/NOTICE
@@ -0,0 +1,32 @@
+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
+ */
diff --git a/bundles/org.openhab.transform.basicprofiles/README.md b/bundles/org.openhab.transform.basicprofiles/README.md
new file mode 100644
index 00000000000..e934a1fefb7
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/README.md
@@ -0,0 +1,210 @@
+# 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"]
+}
+```
diff --git a/bundles/org.openhab.transform.basicprofiles/pom.xml b/bundles/org.openhab.transform.basicprofiles/pom.xml
new file mode 100644
index 00000000000..8ccc07de4e4
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.2.0-SNAPSHOT
+
+
+ org.openhab.transform.basicprofiles
+
+ openHAB Add-ons :: Bundles :: Transformation Service :: Basic Profiles
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/feature/feature.xml b/bundles/org.openhab.transform.basicprofiles/src/main/feature/feature.xml
new file mode 100644
index 00000000000..df5577c02f2
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.transform.basicprofiles/${project.version}
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/BasicProfilesConstants.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/BasicProfilesConstants.java
new file mode 100644
index 00000000000..fbea317e324
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/BasicProfilesConstants.java
@@ -0,0 +1,25 @@
+/**
+ * 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";
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceCountingStateProfileConfig.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceCountingStateProfileConfig.java
new file mode 100644
index 00000000000..92fb647e00c
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceCountingStateProfileConfig.java
@@ -0,0 +1,26 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceTimeStateProfileConfig.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceTimeStateProfileConfig.java
new file mode 100644
index 00000000000..5153ea07003
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceTimeStateProfileConfig.java
@@ -0,0 +1,38 @@
+/**
+ * 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
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/RoundStateProfileConfig.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/RoundStateProfileConfig.java
new file mode 100644
index 00000000000..00cc2b65f4e
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/RoundStateProfileConfig.java
@@ -0,0 +1,30 @@
+/**
+ * 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();
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/StateFilterProfileConfig.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/StateFilterProfileConfig.java
new file mode 100644
index 00000000000..89429374bd4
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/StateFilterProfileConfig.java
@@ -0,0 +1,32 @@
+/**
+ * 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 = ",";
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/ThresholdStateProfileConfig.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/ThresholdStateProfileConfig.java
new file mode 100644
index 00000000000..62037d84bfa
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/ThresholdStateProfileConfig.java
@@ -0,0 +1,26 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/TimeRangeCommandProfileConfig.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/TimeRangeCommandProfileConfig.java
new file mode 100644
index 00000000000..d1800aeb92b
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/TimeRangeCommandProfileConfig.java
@@ -0,0 +1,30 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java
new file mode 100644
index 00000000000..8f1da9adedb
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java
@@ -0,0 +1,199 @@
+/**
+ * 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 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 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 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 getProfileTypes(@Nullable Locale locale) {
+ return SUPPORTED_PROFILE_TYPES.stream().map(p -> createLocalizedProfileType(p, locale)).toList();
+ }
+
+ @Override
+ public Collection 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;
+ }
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/AbstractTriggerProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/AbstractTriggerProfile.java
new file mode 100644
index 00000000000..15f9fe004a0
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/AbstractTriggerProfile.java
@@ -0,0 +1,63 @@
+/**
+ * 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 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 values = new ArrayList<>();
+ for (String event : (Iterable) 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();
+ }
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceCountingStateProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceCountingStateProfile.java
new file mode 100644
index 00000000000..2ae49756493
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceCountingStateProfile.java
@@ -0,0 +1,106 @@
+/**
+ * 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);
+ }
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceTimeStateProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceTimeStateProfile.java
new file mode 100644
index 00000000000..bc17217f5d8
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceTimeStateProfile.java
@@ -0,0 +1,181 @@
+/**
+ * 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);
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/GenericCommandTriggerProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/GenericCommandTriggerProfile.java
new file mode 100644
index 00000000000..ef8615f4f54
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/GenericCommandTriggerProfile.java
@@ -0,0 +1,91 @@
+/**
+ * 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> 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
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/GenericToggleSwitchTriggerProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/GenericToggleSwitchTriggerProfile.java
new file mode 100644
index 00000000000..5cc29cebd4e
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/GenericToggleSwitchTriggerProfile.java
@@ -0,0 +1,57 @@
+/**
+ * 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);
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/InvertStateProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/InvertStateProfile.java
new file mode 100644
index 00000000000..579190c17a8
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/InvertStateProfile.java
@@ -0,0 +1,116 @@
+/**
+ * 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;
+ }
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfile.java
new file mode 100644
index 00000000000..e89d3623683
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfile.java
@@ -0,0 +1,125 @@
+/**
+ * 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;
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java
new file mode 100644
index 00000000000..7e0c8f99472
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java
@@ -0,0 +1,219 @@
+/**
+ * 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> acceptedDataTypes;
+
+ private List 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 parseConditions(@Nullable String config, String separator) {
+ if (config == null) {
+ return List.of();
+ }
+
+ List 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 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
+ + "'}'";
+ }
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/ThresholdStateProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/ThresholdStateProfile.java
new file mode 100644
index 00000000000..9601530faef
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/ThresholdStateProfile.java
@@ -0,0 +1,86 @@
+/**
+ * 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;
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/TimeRangeCommandProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/TimeRangeCommandProfile.java
new file mode 100644
index 00000000000..f4dc35d9e0f
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/TimeRangeCommandProfile.java
@@ -0,0 +1,222 @@
+/**
+ * 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;
+ }
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 00000000000..a2caab921c0
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,11 @@
+
+
+
+ transformation
+ Basic Profiles
+ A set of profiles with basic functionality.
+ local
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/debounce.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/debounce.xml
new file mode 100644
index 00000000000..e87f6ca53d1
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/debounce.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ Number of changes before updating Item State.
+ 1
+
+
+
+
+
+
+ Timespan before an value is forwarded to the item (or discarded after the first value).
+ 0
+
+
+
+ Timespan before an value is forwarded to the handler (or discarded after the first value).
+ 0
+
+
+
+
+
+
+
+ LAST
+ true
+
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/generic-command.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/generic-command.xml
new file mode 100644
index 00000000000..41b6f00afb3
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/generic-command.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+ Comma separated list of events to which the profile should listen.
+
+
+
+ Command which should be sent if the event is triggered.
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/round.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/round.xml
new file mode 100644
index 00000000000..5f9d35c73f8
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/round.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Scale to indicate the resulting number of decimal places.
+
+
+
+ Rounding mode to be used (e.g. "UP", "DOWN", "CEILING", "FLOOR", "HALF_UP" or "HALF_DOWN").
+
+ HALF_UP
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml
new file mode 100644
index 00000000000..c468dcd9fdd
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ 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'".
+
+
+
+ State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType`
+
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/threshold.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/threshold.xml
new file mode 100644
index 00000000000..28eebbd404c
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/threshold.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Triggers ON if value is below the given threshold, otherwise OFF.
+ 10
+
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/time-range-command.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/time-range-command.xml
new file mode 100644
index 00000000000..6c0826a395c
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/time-range-command.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ The value which will be send when the profile detects ON and current time is between start time and end
+ time.
+ 100
+
+
+
+ The value which will be send when the profile detects ON and current time is NOT between start time and
+ end time.
+ 30
+
+
+
+ The start time of the day (hh:mm).
+ time
+
+
+
+ The end time of the day (hh:mm).
+ time
+
+
+ true
+
+ Select what should happen when the profile detects OFF again.
+
+
+
+
+
+ false
+ OFF
+
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/toggle-switch.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/toggle-switch.xml
new file mode 100644
index 00000000000..58c1120f25b
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/toggle-switch.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Comma separated list of events to which the profile should listen.
+
+
+
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties
new file mode 100644
index 00000000000..0b287c6c11c
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties
@@ -0,0 +1,49 @@
+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
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java
new file mode 100644
index 00000000000..cb4a71f0102
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java
@@ -0,0 +1,101 @@
+/**
+ * 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 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 supportedProfileTypeUIDs = profileFactory.getSupportedProfileTypeUIDs();
+ assertThat(supportedProfileTypeUIDs, hasSize(NUMBER_OF_PROFILES));
+
+ Collection 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));
+ }
+ }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceCountingStateProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceCountingStateProfileTest.java
new file mode 100644
index 00000000000..330efe3ab34
--- /dev/null
+++ b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceCountingStateProfileTest.java
@@ -0,0 +1,143 @@
+/**
+ * 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 sourceStates;
+ public final List resultingStates;
+ public final int numberOfChanges;
+
+ public ParameterSet(List sourceStates, List resultingStates, int numberOfChanges) {
+ this.sourceStates = sourceStates;
+ this.resultingStates = resultingStates;
+ this.numberOfChanges = numberOfChanges;
+ }
+ }
+
+ public static Collection