]> git.basschouten.com Git - openhab-addons.git/commitdiff
[basicprofiles] Initial contribution (#16754)
authorJ-N-K <github@klug.nrw>
Tue, 28 May 2024 21:30:46 +0000 (23:30 +0200)
committerGitHub <noreply@github.com>
Tue, 28 May 2024 21:30:46 +0000 (23:30 +0200)
* [basicprofiles] Initial contribution

A set of basic profiles with general use cases. See documentation for details.

Also-By: Christoph Weitkamp <github@christophweitkamp.de>
Also-By: Arne Seime <arne.seime@gmail.com>
Signed-off-by: Jan N. Klug <github@klug.nrw>
41 files changed:
bom/openhab-addons/pom.xml
bundles/org.openhab.transform.basicprofiles/NOTICE [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/README.md [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/pom.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/BasicProfilesConstants.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceCountingStateProfileConfig.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/DebounceTimeStateProfileConfig.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/RoundStateProfileConfig.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/StateFilterProfileConfig.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/ThresholdStateProfileConfig.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/TimeRangeCommandProfileConfig.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/AbstractTriggerProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceCountingStateProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceTimeStateProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/GenericCommandTriggerProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/GenericToggleSwitchTriggerProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/InvertStateProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/ThresholdStateProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/TimeRangeCommandProfile.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/debounce.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/generic-command.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/round.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/threshold.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/time-range-command.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/toggle-switch.xml [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/DebounceCountingStateProfileTest.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/GenericCommandTriggerProfileTest.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/GenericToggleSwitchTriggerProfileTest.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/InvertStateProfileTest.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfileTest.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java [new file with mode: 0644]
bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/ThresholdStateProfileTest.java [new file with mode: 0644]
bundles/pom.xml

index ae6156a70c0c41cc2bb45bf0b7b7f763704df558..86fba9265726397da1e06985e53c4151ca2f9f41 100644 (file)
       <artifactId>org.openhab.persistence.rrd4j</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.transform.basicprofiles</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.transform.bin2json</artifactId>
diff --git a/bundles/org.openhab.transform.basicprofiles/NOTICE b/bundles/org.openhab.transform.basicprofiles/NOTICE
new file mode 100644 (file)
index 0000000..2e65467
--- /dev/null
@@ -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 (file)
index 0000000..e934a1f
--- /dev/null
@@ -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 (file)
index 0000000..8ccc07d
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.2.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.transform.basicprofiles</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Transformation Service :: Basic Profiles</name>
+
+</project>
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 (file)
index 0000000..df5577c
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.transform.basicprofiles-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-transformation-basicprofiles" description="Basic Profiles" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="75">mvn:org.openhab.addons.bundles/org.openhab.transform.basicprofiles/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..fbea317
--- /dev/null
@@ -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 (file)
index 0000000..92fb647
--- /dev/null
@@ -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 (file)
index 0000000..5153ea0
--- /dev/null
@@ -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 (file)
index 0000000..00cc2b6
--- /dev/null
@@ -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 (file)
index 0000000..8942937
--- /dev/null
@@ -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 (file)
index 0000000..62037d8
--- /dev/null
@@ -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 (file)
index 0000000..d1800ae
--- /dev/null
@@ -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 (file)
index 0000000..8f1da9a
--- /dev/null
@@ -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<ProfileTypeUID> SUPPORTED_PROFILE_TYPE_UIDS = Set.of(GENERIC_COMMAND_UID,
+            GENERIC_TOGGLE_SWITCH_UID, DEBOUNCE_COUNTING_UID, DEBOUNCE_TIME_UID, INVERT_UID, ROUND_UID, THRESHOLD_UID,
+            TIME_RANGE_COMMAND_UID, STATE_FILTER_UID);
+    private static final Set<ProfileType> SUPPORTED_PROFILE_TYPES = Set.of(PROFILE_TYPE_GENERIC_COMMAND,
+            PROFILE_TYPE_GENERIC_TOGGLE_SWITCH, PROFILE_TYPE_DEBOUNCE_COUNTING, PROFILE_TYPE_DEBOUNCE_TIME,
+            PROFILE_TYPE_INVERT, PROFILE_TYPE_ROUND, PROFILE_TYPE_THRESHOLD, PROFILE_TYPE_TIME_RANGE_COMMAND,
+            PROFILE_STATE_FILTER);
+
+    private final Map<LocalizedKey, ProfileType> localizedProfileTypeCache = new ConcurrentHashMap<>();
+
+    private final ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService;
+    private final Bundle bundle;
+    private final ItemRegistry itemRegistry;
+    private final TimeZoneProvider timeZoneProvider;
+
+    @Activate
+    public BasicProfilesFactory(final @Reference ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService,
+            final @Reference BundleResolver bundleResolver, @Reference ItemRegistry itemRegistry,
+            @Reference TimeZoneProvider timeZoneProvider) {
+        this.profileTypeI18nLocalizationService = profileTypeI18nLocalizationService;
+        this.bundle = bundleResolver.resolveBundle(BasicProfilesFactory.class);
+        this.itemRegistry = itemRegistry;
+        this.timeZoneProvider = timeZoneProvider;
+    }
+
+    @Override
+    public @Nullable Profile createProfile(ProfileTypeUID profileTypeUID, ProfileCallback callback,
+            ProfileContext context) {
+        if (GENERIC_COMMAND_UID.equals(profileTypeUID)) {
+            return new GenericCommandTriggerProfile(callback, context);
+        } else if (GENERIC_TOGGLE_SWITCH_UID.equals(profileTypeUID)) {
+            return new GenericToggleSwitchTriggerProfile(callback, context);
+        } else if (DEBOUNCE_COUNTING_UID.equals(profileTypeUID)) {
+            return new DebounceCountingStateProfile(callback, context);
+        } else if (DEBOUNCE_TIME_UID.equals(profileTypeUID)) {
+            return new DebounceTimeStateProfile(callback, context);
+        } else if (INVERT_UID.equals(profileTypeUID)) {
+            return new InvertStateProfile(callback);
+        } else if (ROUND_UID.equals(profileTypeUID)) {
+            return new RoundStateProfile(callback, context);
+        } else if (THRESHOLD_UID.equals(profileTypeUID)) {
+            return new ThresholdStateProfile(callback, context);
+        } else if (TIME_RANGE_COMMAND_UID.equals(profileTypeUID)) {
+            return new TimeRangeCommandProfile(callback, context, timeZoneProvider);
+        } else if (STATE_FILTER_UID.equals(profileTypeUID)) {
+            return new StateFilterProfile(callback, context, itemRegistry);
+        }
+        return null;
+    }
+
+    @Override
+    public Collection<ProfileType> getProfileTypes(@Nullable Locale locale) {
+        return SUPPORTED_PROFILE_TYPES.stream().map(p -> createLocalizedProfileType(p, locale)).toList();
+    }
+
+    @Override
+    public Collection<ProfileTypeUID> getSupportedProfileTypeUIDs() {
+        return SUPPORTED_PROFILE_TYPE_UIDS;
+    }
+
+    @Override
+    public @Nullable ProfileTypeUID getSuggestedProfileTypeUID(ChannelType channelType, @Nullable String itemType) {
+        return null;
+    }
+
+    @Override
+    public @Nullable ProfileTypeUID getSuggestedProfileTypeUID(Channel channel, @Nullable String itemType) {
+        return null;
+    }
+
+    private ProfileType createLocalizedProfileType(ProfileType profileType, @Nullable Locale locale) {
+        final LocalizedKey localizedKey = new LocalizedKey(profileType.getUID(),
+                locale != null ? locale.toLanguageTag() : null);
+
+        final ProfileType cachedlocalizedProfileType = localizedProfileTypeCache.get(localizedKey);
+        if (cachedlocalizedProfileType != null) {
+            return cachedlocalizedProfileType;
+        }
+
+        final ProfileType localizedProfileType = profileTypeI18nLocalizationService.createLocalizedProfileType(bundle,
+                profileType, locale);
+        if (localizedProfileType != null) {
+            localizedProfileTypeCache.put(localizedKey, localizedProfileType);
+            return localizedProfileType;
+        } else {
+            return profileType;
+        }
+    }
+}
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 (file)
index 0000000..15f9fe0
--- /dev/null
@@ -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<String> events;
+
+    @SuppressWarnings("unchecked")
+    AbstractTriggerProfile(ProfileCallback callback, ProfileContext context) {
+        this.callback = callback;
+
+        Object paramValue = context.getConfiguration().get(PARAM_EVENTS);
+        logger.trace("Configuring profile '{}' with '{}' parameter: '{}'", getProfileTypeUID(), PARAM_EVENTS,
+                paramValue);
+        if (paramValue instanceof String) {
+            String event = paramValue.toString();
+            events = Collections.unmodifiableList(Arrays.asList(event.split(",")));
+        } else if (paramValue instanceof Iterable) {
+            List<String> values = new ArrayList<>();
+            for (String event : (Iterable<String>) paramValue) {
+                values.add(event);
+            }
+            events = Collections.unmodifiableList(values);
+        } else {
+            logger.error("Parameter '{}' is not a comma separated list of Strings", PARAM_EVENTS);
+            events = List.of();
+        }
+    }
+}
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 (file)
index 0000000..2ae4975
--- /dev/null
@@ -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 (file)
index 0000000..bc17217
--- /dev/null
@@ -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 (file)
index 0000000..ef8615f
--- /dev/null
@@ -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<Class<? extends Command>> SUPPORTED_COMMANDS = List.of(IncreaseDecreaseType.class,
+            NextPreviousType.class, OnOffType.class, PlayPauseType.class, RewindFastforwardType.class,
+            StopMoveType.class, UpDownType.class);
+
+    public static final String PARAM_COMMAND = "command";
+
+    private @Nullable Command command;
+
+    public GenericCommandTriggerProfile(ProfileCallback callback, ProfileContext context) {
+        super(callback, context);
+
+        Object paramValue = context.getConfiguration().get(PARAM_COMMAND);
+        logger.trace("Configuring profile '{}' with '{}' parameter: '{}'", getProfileTypeUID(), PARAM_COMMAND,
+                paramValue);
+        if (paramValue instanceof String value) {
+            command = TypeParser.parseCommand(SUPPORTED_COMMANDS, value);
+            if (command == null) {
+                logger.debug("Value '{}' for parameter '{}' is a not supported command. Using StringType instead.",
+                        value, PARAM_COMMAND);
+                command = StringType.valueOf(value);
+            }
+        } else {
+            logger.error("Parameter '{}' is not of type String: {}", PARAM_COMMAND, paramValue);
+        }
+    }
+
+    @Override
+    public ProfileTypeUID getProfileTypeUID() {
+        return GENERIC_COMMAND_UID;
+    }
+
+    @Override
+    public void onTriggerFromHandler(String payload) {
+        Command c = command;
+        if (c != null && events.contains(payload)) {
+            callback.sendCommand(c);
+        }
+    }
+
+    @Override
+    public void onStateUpdateFromItem(State state) {
+        // do nothing
+    }
+}
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 (file)
index 0000000..5cc29ce
--- /dev/null
@@ -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 (file)
index 0000000..579190c
--- /dev/null
@@ -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 (file)
index 0000000..e89d362
--- /dev/null
@@ -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 (file)
index 0000000..7e0c8f9
--- /dev/null
@@ -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<Class<? extends State>> acceptedDataTypes;
+
+    private List<StateCondition> conditions = List.of();
+
+    private @Nullable State configMismatchState = null;
+
+    public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
+        this.callback = callback;
+        acceptedDataTypes = context.getAcceptedDataTypes();
+        this.itemRegistry = itemRegistry;
+
+        StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
+        if (config != null) {
+            conditions = parseConditions(config.conditions, config.separator);
+            configMismatchState = parseState(config.mismatchState);
+        }
+    }
+
+    private List<StateCondition> parseConditions(@Nullable String config, String separator) {
+        if (config == null) {
+            return List.of();
+        }
+
+        List<StateCondition> parsedConditions = new ArrayList<>();
+        try {
+            String[] expressions = config.split(separator);
+            for (String expression : expressions) {
+                String[] parts = expression.trim().split("\s");
+                if (parts.length == 3) {
+                    String itemName = parts[0];
+                    StateCondition.ComparisonType conditionType = StateCondition.ComparisonType
+                            .valueOf(parts[1].toUpperCase(Locale.ROOT));
+                    String value = parts[2];
+                    parsedConditions.add(new StateCondition(itemName, conditionType, value));
+                } else {
+                    logger.warn("Malformed condition expression: '{}'", expression);
+                }
+            }
+
+            return parsedConditions;
+        } catch (IllegalArgumentException e) {
+            logger.warn("Cannot parse condition {}. Expected format ITEM_NAME <EQ|NEQ> STATE_VALUE: '{}'", config,
+                    e.getMessage());
+            return List.of();
+        }
+    }
+
+    @Override
+    public ProfileTypeUID getProfileTypeUID() {
+        return STATE_FILTER_UID;
+    }
+
+    @Override
+    public void onStateUpdateFromItem(State state) {
+        // do nothing
+    }
+
+    @Override
+    public void onCommandFromItem(Command command) {
+        callback.handleCommand(command);
+    }
+
+    @Override
+    public void onCommandFromHandler(Command command) {
+        callback.sendCommand(command);
+    }
+
+    @Override
+    public void onStateUpdateFromHandler(State state) {
+        State resultState = checkCondition(state);
+        if (resultState != null) {
+            logger.debug("Received state update from handler: {}, forwarded as {}", state, resultState);
+            callback.sendUpdate(resultState);
+        } else {
+            logger.debug("Received state update from handler: {}, not forwarded to item", state);
+        }
+    }
+
+    @Nullable
+    private State checkCondition(State state) {
+        if (!conditions.isEmpty()) {
+            boolean allConditionsMet = true;
+            for (StateCondition condition : conditions) {
+                logger.debug("Evaluting condition: {}", condition);
+                try {
+                    Item item = itemRegistry.getItem(condition.itemName);
+                    String itemState = item.getState().toString();
+
+                    if (!condition.matches(itemState)) {
+                        allConditionsMet = false;
+                    }
+                } catch (ItemNotFoundException e) {
+                    logger.warn(
+                            "Cannot find item '{}' in registry - check your condition expression - skipping state update",
+                            condition.itemName);
+                    allConditionsMet = false;
+                }
+
+            }
+            if (allConditionsMet) {
+                return state;
+            } else {
+                return configMismatchState;
+            }
+        } else {
+            logger.warn(
+                    "No configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update");
+        }
+
+        return null;
+    }
+
+    @Nullable
+    State parseState(@Nullable String stateString) {
+        // Quoted strings are parsed as StringType
+        if (stateString == null) {
+            return null;
+        } else if (stateString.startsWith("'") && stateString.endsWith("'")) {
+            return new StringType(stateString.substring(1, stateString.length() - 1));
+        } else {
+            return TypeParser.parseState(acceptedDataTypes, stateString);
+        }
+    }
+
+    class StateCondition {
+        String itemName;
+
+        ComparisonType comparisonType;
+        String value;
+
+        boolean quoted = false;
+
+        public StateCondition(String itemName, ComparisonType comparisonType, String value) {
+            this.itemName = itemName;
+            this.comparisonType = comparisonType;
+            this.value = value;
+            this.quoted = value.startsWith("'") && value.endsWith("'");
+            if (quoted) {
+                this.value = value.substring(1, value.length() - 1);
+            }
+        }
+
+        public boolean matches(String state) {
+            switch (comparisonType) {
+                case EQ:
+                    return state.equals(value);
+                case NEQ: {
+                    return !state.equals(value);
+                }
+                default:
+                    logger.warn("Unknown condition type {}. Expected 'eq' or 'neq' - skipping state update",
+                            comparisonType);
+                    return false;
+
+            }
+        }
+
+        enum ComparisonType {
+            EQ,
+            NEQ
+        }
+
+        @Override
+        public String toString() {
+            return "Condition{itemName='" + itemName + "', comparisonType=" + comparisonType + ", value='" + value
+                    + "'}'";
+        }
+    }
+}
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 (file)
index 0000000..9601530
--- /dev/null
@@ -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 (file)
index 0000000..f4dc35d
--- /dev/null
@@ -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 (file)
index 0000000..a2caab9
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="basicprofiles" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+       <type>transformation</type>
+       <name>Basic Profiles</name>
+       <description>A set of profiles with basic functionality.</description>
+       <connection>local</connection>
+
+</addon:addon>
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 (file)
index 0000000..e87f6ca
--- /dev/null
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="profile:basic-profiles:debounce-counting">
+               <parameter name="numberOfChanges" type="integer" min="0" step="1">
+                       <label>Number Of Changes</label>
+                       <description>Number of changes before updating Item State.</description>
+                       <default>1</default>
+               </parameter>
+       </config-description>
+
+       <config-description uri="profile:basic-profiles:debounce-time">
+               <parameter name="toItemDelay" type="integer" min="0" step="1" unit="ms">
+                       <label>To Item Delay</label>
+                       <description>Timespan before an value is forwarded to the item (or discarded after the first value).</description>
+                       <default>0</default>
+               </parameter>
+               <parameter name="toHandlerDelay" type="integer" min="0" step="1" unit="ms">
+                       <label>To Handler Delay</label>
+                       <description>Timespan before an value is forwarded to the handler (or discarded after the first value).</description>
+                       <default>0</default>
+               </parameter>
+               <parameter name="mode" type="text">
+                       <label>Mode</label>
+                       <options>
+                               <option value="FIRST">Send first value</option>
+                               <option value="LAST">Send last value</option>
+                       </options>
+                       <default>LAST</default>
+                       <limitToOptions>true</limitToOptions>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..41b6f00
--- /dev/null
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="profile:basic-profiles:generic-command">
+               <parameter name="events" type="text" required="true" multiple="true">
+                       <label>Events</label>
+                       <description>Comma separated list of events to which the profile should listen.</description>
+               </parameter>
+               <parameter name="command" type="text" required="true">
+                       <label>Command</label>
+                       <description>Command which should be sent if the event is triggered.</description>
+                       <limitToOptions>false</limitToOptions>
+                       <options>
+                               <option value="INCREASE">INCREASE</option>
+                               <option value="DECREASE">DECREASE</option>
+                               <option value="NEXT">NEXT</option>
+                               <option value="PREVIOUS">PREVIOUS</option>
+                               <option value="ON">ON</option>
+                               <option value="OFF">OFF</option>
+                               <option value="PLAY">PLAY</option>
+                               <option value="PAUSE">PAUSE</option>
+                               <option value="REWIND">REWIND</option>
+                               <option value="FASTFORWARD">FASTFORWARD</option>
+                               <option value="STOP">STOP</option>
+                               <option value="MOVE">MOVE</option>
+                               <option value="UP">UP</option>
+                               <option value="DOWN">DOWN</option>
+                       </options>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..5f9d35c
--- /dev/null
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="profile:basic-profiles:round">
+               <parameter name="scale" type="integer" min="-16" max="16" step="1" required="true">
+                       <label>Scale</label>
+                       <description>Scale to indicate the resulting number of decimal places.</description>
+               </parameter>
+               <parameter name="mode" type="text">
+                       <label>Rounding Mode</label>
+                       <description>Rounding mode to be used (e.g. "UP", "DOWN", "CEILING", "FLOOR", "HALF_UP" or "HALF_DOWN").
+                       </description>
+                       <default>HALF_UP</default>
+                       <options>
+                               <option value="UP">UP</option>
+                               <option value="DOWN">DOWN</option>
+                               <option value="CEILING">CEILING</option>
+                               <option value="FLOOR">FLOOR</option>
+                               <option value="HALF_UP">HALF_UP</option>
+                               <option value="HALF_DOWN">HALF_DOWN</option>
+                       </options>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..c468dcd
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="profile:basic-profiles:state-filter">
+               <parameter name="conditions" type="text" required="true">
+                       <label>Conditions</label>
+                       <description>Comma separated list of expressions on the format ITEM_NAME OPERATOR ITEM_STATE, ie "MyItem EQ OFF". Use
+                               quotes around ITEM_STATE to treat value as string ie "'OFF'".</description>
+               </parameter>
+               <parameter name="mismatchState" type="text">
+                       <label>State for filter rejects</label>
+                       <description>State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType`</description>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..28eebbd
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="profile:basic-profiles:threshold">
+               <parameter name="threshold" type="integer" min="0" max="100" step="1">
+                       <label>Threshold</label>
+                       <description>Triggers ON if value is below the given threshold, otherwise OFF.</description>
+                       <default>10</default>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..6c0826a
--- /dev/null
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="profile:basic-profiles:time-range-command">
+               <parameter name="inRangeValue" type="integer" min="0" max="100" step="1">
+                       <label>In Range Value</label>
+                       <description>The value which will be send when the profile detects ON and current time is between start time and end
+                               time.</description>
+                       <default>100</default>
+               </parameter>
+               <parameter name="outOfRangeValue" type="integer" min="0" max="100" step="1">
+                       <label>Out Of Range Value</label>
+                       <description>The value which will be send when the profile detects ON and current time is NOT between start time and
+                               end time.</description>
+                       <default>30</default>
+               </parameter>
+               <parameter name="start" type="text" pattern="^([0-1][0-9]|2[0-3])(:[0-5][0-9])$" required="true">
+                       <label>Start Time</label>
+                       <description>The start time of the day (hh:mm).</description>
+                       <context>time</context>
+               </parameter>
+               <parameter name="end" type="text" pattern="^([0-1][0-9]|2[0-3])(:[0-5][0-9])$" required="true">
+                       <label>End Time</label>
+                       <description>The end time of the day (hh:mm).</description>
+                       <context>time</context>
+               </parameter>
+               <parameter name="restoreValue" type="text">
+                       <advanced>true</advanced>
+                       <label>Restore Value</label>
+                       <description>Select what should happen when the profile detects OFF again.</description>
+                       <options>
+                               <option value="OFF">Off</option>
+                               <option value="PREVIOUS">Return to previous value</option>
+                               <option value="NOTHING">Do nothing</option>
+                       </options>
+                       <limitToOptions>false</limitToOptions>
+                       <default>OFF</default>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..58c1120
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="profile:basic-profiles:toggle-switch">
+               <parameter name="events" type="text" required="true" multiple="true">
+                       <label>Events</label>
+                       <description>Comma separated list of events to which the profile should listen.</description>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..0b287c6
--- /dev/null
@@ -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 (file)
index 0000000..cb4a71f
--- /dev/null
@@ -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<String, Object> PROPERTIES = Map.of(ThresholdStateProfile.PARAM_THRESHOLD, 15,
+            RoundStateProfile.PARAM_SCALE, 2, GenericCommandTriggerProfile.PARAM_EVENTS, "1002,1003",
+            GenericCommandTriggerProfile.PARAM_COMMAND, OnOffType.ON.toString(), TimeRangeCommandProfile.PARAM_START,
+            "08:00", TimeRangeCommandProfile.PARAM_END, "23:00");
+    private static final Configuration CONFIG = new Configuration(PROPERTIES);
+
+    private @Mock @NonNullByDefault({}) ProfileTypeI18nLocalizationService mockLocalizationService;
+    private @Mock @NonNullByDefault({}) TimeZoneProvider mockTimeZoneProvider;
+    private @Mock @NonNullByDefault({}) BundleResolver mockBundleResolver;
+    private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+    private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+    private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry;
+
+    private @NonNullByDefault({}) BasicProfilesFactory profileFactory;
+
+    @BeforeEach
+    public void setup() {
+        profileFactory = new BasicProfilesFactory(mockLocalizationService, mockBundleResolver, mockItemRegistry,
+                mockTimeZoneProvider);
+
+        when(mockContext.getConfiguration()).thenReturn(CONFIG);
+    }
+
+    @Test
+    public void systemProfileTypesAndUidsShouldBeAvailable() {
+        Collection<ProfileTypeUID> supportedProfileTypeUIDs = profileFactory.getSupportedProfileTypeUIDs();
+        assertThat(supportedProfileTypeUIDs, hasSize(NUMBER_OF_PROFILES));
+
+        Collection<ProfileType> supportedProfileTypes = profileFactory.getProfileTypes(null);
+        assertThat(supportedProfileTypeUIDs, hasSize(NUMBER_OF_PROFILES));
+
+        for (ProfileType profileType : supportedProfileTypes) {
+            assertTrue(supportedProfileTypeUIDs.contains(profileType.getUID()));
+        }
+    }
+
+    @Test
+    public void testFactoryCreatesAvailableProfiles() {
+        for (ProfileTypeUID profileTypeUID : profileFactory.getSupportedProfileTypeUIDs()) {
+            assertNotNull(profileFactory.createProfile(profileTypeUID, mockCallback, mockContext));
+        }
+    }
+}
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 (file)
index 0000000..330efe3
--- /dev/null
@@ -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<State> sourceStates;
+        public final List<State> resultingStates;
+        public final int numberOfChanges;
+
+        public ParameterSet(List<State> sourceStates, List<State> resultingStates, int numberOfChanges) {
+            this.sourceStates = sourceStates;
+            this.resultingStates = resultingStates;
+            this.numberOfChanges = numberOfChanges;
+        }
+    }
+
+    public static Collection<Object[]> parameters() {
+        return Arrays.asList(new Object[][] { //
+                { new ParameterSet(List.of(OnOffType.ON), List.of(OnOffType.ON), 0) }, //
+                { new ParameterSet(List.of(OnOffType.ON), List.of(OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON), List.of(OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF), List.of(OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.OFF), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.OFF), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.OFF), 1) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.ON, OnOffType.OFF, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.ON, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF, OnOffType.ON),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.ON), 2) }, //
+                { new ParameterSet(List.of(OnOffType.ON, OnOffType.OFF, OnOffType.OFF, OnOffType.OFF),
+                        List.of(OnOffType.ON, OnOffType.ON, OnOffType.ON, OnOffType.OFF), 2) } //
+        });
+    }
+
+    private @NonNullByDefault({}) @Mock ProfileCallback mockCallback;
+    private @NonNullByDefault({}) @Mock ProfileContext mockContext;
+
+    @Test
+    public void testWrongParameterLower() {
+        assertThrows(IllegalArgumentException.class, () -> initProfile(-1));
+    }
+
+    @ParameterizedTest
+    @MethodSource("parameters")
+    public void testOnStateUpdateFromHandler(ParameterSet parameterSet) {
+        final StateProfile profile = initProfile(parameterSet.numberOfChanges);
+        for (int i = 0; i < parameterSet.sourceStates.size(); i++) {
+            verifySendUpdate(profile, parameterSet.sourceStates.get(i), parameterSet.resultingStates.get(i));
+        }
+    }
+
+    private StateProfile initProfile(int numberOfChanges) {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("numberOfChanges", numberOfChanges)));
+        return new DebounceCountingStateProfile(mockCallback, mockContext);
+    }
+
+    private void verifySendUpdate(StateProfile profile, State state, State expectedState) {
+        reset(mockCallback);
+        profile.onStateUpdateFromHandler(state);
+        verify(mockCallback, times(1)).sendUpdate(eq(expectedState));
+    }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/GenericCommandTriggerProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/GenericCommandTriggerProfileTest.java
new file mode 100644 (file)
index 0000000..2c31311
--- /dev/null
@@ -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.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.StopMoveType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
+import org.openhab.core.thing.CommonTriggerEvents;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.TriggerProfile;
+import org.openhab.core.types.Command;
+
+/**
+ * Basic unit tests for {@link GenericCommandTriggerProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class GenericCommandTriggerProfileTest {
+
+    public static class ParameterSet {
+        public final String events;
+        public final Command command;
+
+        public ParameterSet(String events, Command command) {
+            this.events = events;
+            this.command = command;
+        }
+    }
+
+    public static Collection<Object[]> parameters() {
+        return List.of(new Object[][] { //
+                { new ParameterSet("1002", OnOffType.ON) }, //
+                { new ParameterSet("1002", OnOffType.OFF) }, //
+                { new ParameterSet("1002,1003", PlayPauseType.PLAY) }, //
+                { new ParameterSet("1002,1003", PlayPauseType.PAUSE) }, //
+                { new ParameterSet("1002,1003,3001", StopMoveType.STOP) }, //
+                { new ParameterSet("1002,1003,3001", StopMoveType.MOVE) }, //
+                { new ParameterSet(CommonTriggerEvents.LONG_PRESSED + "," + CommonTriggerEvents.SHORT_PRESSED,
+                        UpDownType.UP) }, //
+                { new ParameterSet(CommonTriggerEvents.LONG_PRESSED + "," + CommonTriggerEvents.SHORT_PRESSED,
+                        UpDownType.DOWN) }, //
+                { new ParameterSet("1003", StringType.valueOf("SELECT")) } //
+        });
+    }
+
+    private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+    private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+
+    @ParameterizedTest
+    @MethodSource("parameters")
+    public void testOnOffSwitchItem(ParameterSet parameterSet) {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of(AbstractTriggerProfile.PARAM_EVENTS,
+                parameterSet.events, GenericCommandTriggerProfile.PARAM_COMMAND, parameterSet.command.toFullString())));
+
+        TriggerProfile profile = new GenericCommandTriggerProfile(mockCallback, mockContext);
+
+        verifyNoAction(profile, CommonTriggerEvents.PRESSED, parameterSet.command);
+        for (String event : parameterSet.events.split(",")) {
+            verifyAction(profile, event, parameterSet.command);
+        }
+    }
+
+    private void verifyAction(TriggerProfile profile, String trigger, Command expectation) {
+        reset(mockCallback);
+        profile.onTriggerFromHandler(trigger);
+        verify(mockCallback, times(1)).sendCommand(eq(expectation));
+    }
+
+    private void verifyNoAction(TriggerProfile profile, String trigger, Command expectation) {
+        reset(mockCallback);
+        profile.onTriggerFromHandler(trigger);
+        verify(mockCallback, times(0)).sendCommand(eq(expectation));
+    }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/GenericToggleSwitchTriggerProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/GenericToggleSwitchTriggerProfileTest.java
new file mode 100644 (file)
index 0000000..616ec5b
--- /dev/null
@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.transform.basicprofiles.internal.profiles;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.thing.CommonTriggerEvents;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.TriggerProfile;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Basic unit tests for {@link GenericToggleSwitchTriggerProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class GenericToggleSwitchTriggerProfileTest {
+
+    private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+    private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+
+    @BeforeEach
+    public void setup() {
+        when(mockContext.getConfiguration()).thenReturn(
+                new Configuration(Map.of(AbstractTriggerProfile.PARAM_EVENTS, CommonTriggerEvents.PRESSED)));
+    }
+
+    @Test
+    public void testSwitchItem() {
+        TriggerProfile profile = new GenericToggleSwitchTriggerProfile(mockCallback, mockContext);
+
+        verifyAction(profile, UnDefType.NULL, OnOffType.ON);
+        verifyAction(profile, OnOffType.ON, OnOffType.OFF);
+        verifyAction(profile, OnOffType.OFF, OnOffType.ON);
+    }
+
+    @Test
+    public void testDimmerItem() {
+        TriggerProfile profile = new GenericToggleSwitchTriggerProfile(mockCallback, mockContext);
+
+        verifyAction(profile, UnDefType.NULL, OnOffType.ON);
+        verifyAction(profile, PercentType.HUNDRED, OnOffType.OFF);
+        verifyAction(profile, PercentType.ZERO, OnOffType.ON);
+        verifyAction(profile, new PercentType(50), OnOffType.OFF);
+    }
+
+    @Test
+    public void testColorItem() {
+        TriggerProfile profile = new GenericToggleSwitchTriggerProfile(mockCallback, mockContext);
+
+        verifyAction(profile, UnDefType.NULL, OnOffType.ON);
+        verifyAction(profile, HSBType.WHITE, OnOffType.OFF);
+        verifyAction(profile, HSBType.BLACK, OnOffType.ON);
+        verifyAction(profile, new HSBType("0,50,50"), OnOffType.OFF);
+    }
+
+    private void verifyAction(TriggerProfile profile, State preCondition, Command expectation) {
+        reset(mockCallback);
+        profile.onStateUpdateFromItem(preCondition);
+        profile.onTriggerFromHandler(CommonTriggerEvents.PRESSED);
+        verify(mockCallback, times(1)).sendCommand(eq(expectation));
+    }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/InvertStateProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/InvertStateProfileTest.java
new file mode 100644 (file)
index 0000000..59b94a4
--- /dev/null
@@ -0,0 +1,121 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.transform.basicprofiles.internal.profiles;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.StateProfile;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.Type;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Basic unit tests for {@link InvertStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class InvertStateProfileTest {
+
+    private static final DateTimeType NOW = new DateTimeType();
+
+    public static class ParameterSet {
+        public Type type;
+        public Type resultingType;
+
+        public ParameterSet(Type source, Type result) {
+            this.type = source;
+            this.resultingType = result;
+        }
+    }
+
+    public static Collection<Object[]> parameters() {
+        return List.of(new Object[][] { //
+                { new ParameterSet(UnDefType.NULL, UnDefType.NULL) }, //
+                { new ParameterSet(UnDefType.UNDEF, UnDefType.UNDEF) }, //
+                { new ParameterSet(new QuantityType<>(25, Units.LITRE), new QuantityType<>(-25, Units.LITRE)) }, //
+                { new ParameterSet(PercentType.ZERO, PercentType.HUNDRED) }, //
+                { new ParameterSet(PercentType.HUNDRED, PercentType.ZERO) }, //
+                { new ParameterSet(new PercentType(25), new PercentType(75)) }, //
+                { new ParameterSet(new DecimalType(25L), new DecimalType(-25L)) }, //
+                { new ParameterSet(OnOffType.ON, OnOffType.OFF) }, //
+                { new ParameterSet(OnOffType.OFF, OnOffType.ON) }, //
+                { new ParameterSet(NOW, NOW) } //
+        });
+    }
+
+    private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+
+    @ParameterizedTest
+    @MethodSource("parameters")
+    public void testOnCommandFromHandler(ParameterSet parameterSet) {
+        final StateProfile profile = initProfile();
+        if (parameterSet.type instanceof Command && parameterSet.resultingType instanceof Command) {
+            verifyCommandFromItem(profile, (Command) parameterSet.type, (Command) parameterSet.resultingType);
+            verifyCommandFromHandler(profile, (Command) parameterSet.type, (Command) parameterSet.resultingType);
+        }
+    }
+
+    @ParameterizedTest
+    @MethodSource("parameters")
+    public void testOnStateUpdateFromHandler(ParameterSet parameterSet) {
+        final StateProfile profile = initProfile();
+        if (parameterSet.type instanceof State && parameterSet.resultingType instanceof State) {
+            verifyStateUpdateFromHandler(profile, (State) parameterSet.type, (State) parameterSet.resultingType);
+        }
+    }
+
+    private StateProfile initProfile() {
+        return new InvertStateProfile(mockCallback);
+    }
+
+    private void verifyCommandFromItem(StateProfile profile, Command command, Command result) {
+        reset(mockCallback);
+        profile.onCommandFromItem(command);
+        verify(mockCallback, times(1)).handleCommand(eq(result));
+    }
+
+    private void verifyCommandFromHandler(StateProfile profile, Command command, Command result) {
+        reset(mockCallback);
+        profile.onCommandFromHandler(command);
+        verify(mockCallback, times(1)).sendCommand(eq(result));
+    }
+
+    private void verifyStateUpdateFromHandler(StateProfile profile, State state, State result) {
+        reset(mockCallback);
+        profile.onStateUpdateFromHandler(state);
+        verify(mockCallback, times(1)).sendUpdate(eq(result));
+    }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfileTest.java
new file mode 100644 (file)
index 0000000..5a359de
--- /dev/null
@@ -0,0 +1,157 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.transform.basicprofiles.internal.profiles;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.Mockito.*;
+
+import java.math.RoundingMode;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.ImperialUnits;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.types.Command;
+
+/**
+ * Basic unit tests for {@link RoundStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@NonNullByDefault
+public class RoundStateProfileTest {
+
+    @BeforeEach
+    public void setup() {
+        // initialize parser with ImperialUnits, otherwise units like °F are unknown
+        @SuppressWarnings("unused")
+        Unit<Temperature> fahrenheit = ImperialUnits.FAHRENHEIT;
+    }
+
+    @Test
+    public void testParsingParameters() {
+        ProfileCallback callback = mock(ProfileCallback.class);
+        RoundStateProfile roundProfile = createProfile(callback, 2, "NOT_SUPPORTED");
+
+        assertThat(roundProfile.scale, is(2));
+        assertThat(roundProfile.roundingMode, is(RoundingMode.HALF_UP));
+    }
+
+    @Test
+    public void testDecimalTypeOnCommandFromItem() {
+        ProfileCallback callback = mock(ProfileCallback.class);
+        RoundStateProfile roundProfile = createProfile(callback, 2);
+
+        Command cmd = new DecimalType(23.333);
+        roundProfile.onCommandFromItem(cmd);
+
+        ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+        verify(callback, times(1)).handleCommand(capture.capture());
+
+        Command result = capture.getValue();
+        DecimalType dtResult = (DecimalType) result;
+        assertThat(dtResult.doubleValue(), is(23.33));
+    }
+
+    @Test
+    public void testDecimalTypeOnCommandFromItemWithNegativeScale() {
+        ProfileCallback callback = mock(ProfileCallback.class);
+        RoundStateProfile roundProfile = createProfile(callback, -2);
+
+        Command cmd = new DecimalType(1234.333);
+        roundProfile.onCommandFromItem(cmd);
+
+        ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+        verify(callback, times(1)).handleCommand(capture.capture());
+
+        Command result = capture.getValue();
+        DecimalType dtResult = (DecimalType) result;
+        assertThat(dtResult.doubleValue(), is(1200.0));
+    }
+
+    @Test
+    public void testDecimalTypeOnCommandFromItemWithCeiling() {
+        ProfileCallback callback = mock(ProfileCallback.class);
+        RoundStateProfile roundProfile = createProfile(callback, 0, RoundingMode.CEILING.name());
+
+        Command cmd = new DecimalType(23.3);
+        roundProfile.onCommandFromItem(cmd);
+
+        ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+        verify(callback, times(1)).handleCommand(capture.capture());
+
+        Command result = capture.getValue();
+        DecimalType dtResult = (DecimalType) result;
+        assertThat(dtResult.doubleValue(), is(24.0));
+    }
+
+    @Test
+    public void testDecimalTypeOnCommandFromItemWithFloor() {
+        ProfileCallback callback = mock(ProfileCallback.class);
+        RoundStateProfile roundProfile = createProfile(callback, 0, RoundingMode.FLOOR.name());
+
+        Command cmd = new DecimalType(23.6);
+        roundProfile.onCommandFromItem(cmd);
+
+        ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+        verify(callback, times(1)).handleCommand(capture.capture());
+
+        Command result = capture.getValue();
+        DecimalType dtResult = (DecimalType) result;
+        assertThat(dtResult.doubleValue(), is(23.0));
+    }
+
+    @Test
+    public void testQuantityTypeOnCommandFromItem() {
+        ProfileCallback callback = mock(ProfileCallback.class);
+        RoundStateProfile roundProfile = createProfile(callback, 1);
+
+        Command cmd = new QuantityType<Temperature>("23.333 °C");
+        roundProfile.onCommandFromItem(cmd);
+
+        ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
+        verify(callback, times(1)).handleCommand(capture.capture());
+
+        Command result = capture.getValue();
+        @SuppressWarnings("unchecked")
+        QuantityType<Temperature> qtResult = (QuantityType<Temperature>) result;
+        assertThat(qtResult.doubleValue(), is(23.3));
+        assertThat(qtResult.getUnit(), is(SIUnits.CELSIUS));
+    }
+
+    private RoundStateProfile createProfile(ProfileCallback callback, Integer scale) {
+        return createProfile(callback, scale, null);
+    }
+
+    private RoundStateProfile createProfile(ProfileCallback callback, Integer scale, @Nullable String mode) {
+        ProfileContext context = mock(ProfileContext.class);
+        Configuration config = new Configuration();
+        config.put(RoundStateProfile.PARAM_SCALE, scale);
+        config.put(RoundStateProfile.PARAM_MODE, mode == null ? RoundingMode.HALF_UP.name() : mode);
+        when(context.getConfiguration()).thenReturn(config);
+
+        return new RoundStateProfile(callback, context);
+    }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java
new file mode 100644 (file)
index 0000000..bb16fa7
--- /dev/null
@@ -0,0 +1,216 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.transform.basicprofiles.internal.profiles;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.items.Item;
+import org.openhab.core.items.ItemNotFoundException;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.library.items.StringItem;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Basic unit tests for {@link StateFilterProfile}.
+ *
+ * @author Arne Seime - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class StateFilterProfileTest {
+
+    private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+    private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+    private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry;
+
+    @BeforeEach
+    public void setup() {
+        reset(mockContext);
+        reset(mockCallback);
+    }
+
+    @Test
+    public void testNoConditions() {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "")));
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        State expectation = OnOffType.ON;
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+    }
+
+    @Test
+    public void testMalformedConditions() {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName invalid")));
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        State expectation = OnOffType.ON;
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+    }
+
+    @Test
+    public void testInvalidComparatorConditions() throws ItemNotFoundException {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName lt Value")));
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+        when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class);
+
+        State expectation = OnOffType.ON;
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+    }
+
+    @Test
+    public void testInvalidItemConditions() throws ItemNotFoundException {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value")));
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class);
+        State expectation = OnOffType.ON;
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+    }
+
+    @Test
+    public void testInvalidMultipleConditions() throws ItemNotFoundException {
+        when(mockContext.getConfiguration())
+                .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value,itemname invalid")));
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+        when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class);
+
+        State expectation = OnOffType.ON;
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+    }
+
+    @Test
+    public void testSingleConditionMatch() throws ItemNotFoundException {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value")));
+        when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
+
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        State expectation = new StringType("NewValue");
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(1)).sendUpdate(eq(expectation));
+    }
+
+    @Test
+    public void testSingleConditionMatchQuoted() throws ItemNotFoundException {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq 'Value'")));
+        when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
+
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        State expectation = new StringType("NewValue");
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(1)).sendUpdate(eq(expectation));
+    }
+
+    private Item stringItemWithState(String itemName, String value) {
+        StringItem item = new StringItem(itemName);
+        item.setState(new StringType(value));
+        return item;
+    }
+
+    @Test
+    public void testMultipleCondition_AllMatch() throws ItemNotFoundException {
+        when(mockContext.getConfiguration())
+                .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value, ItemName2 eq Value2")));
+        when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
+        when(mockItemRegistry.getItem("ItemName2")).thenReturn(stringItemWithState("ItemName2", "Value2"));
+
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        State expectation = new StringType("NewValue");
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(1)).sendUpdate(eq(expectation));
+    }
+
+    @Test
+    public void testMultipleCondition_SingleMatch() throws ItemNotFoundException {
+        when(mockContext.getConfiguration())
+                .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value, ItemName2 eq Value2")));
+        when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
+        when(mockItemRegistry.getItem("ItemName2")).thenThrow(ItemNotFoundException.class);
+
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        State expectation = new StringType("NewValue");
+        profile.onStateUpdateFromHandler(expectation);
+        verify(mockCallback, times(0)).sendUpdate(eq(expectation));
+    }
+
+    @Test
+    public void testFailingConditionWithMismatchState() throws ItemNotFoundException {
+        when(mockContext.getConfiguration())
+                .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value", "mismatchState", "UNDEF")));
+        when(mockContext.getAcceptedDataTypes()).thenReturn(List.of(UnDefType.class, StringType.class));
+        when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Mismatch"));
+
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        profile.onStateUpdateFromHandler(new StringType("ToBeDiscarded"));
+        verify(mockCallback, times(1)).sendUpdate(eq(UnDefType.UNDEF));
+    }
+
+    @Test
+    public void testFailingConditionWithMismatchStateQuoted() throws ItemNotFoundException {
+        when(mockContext.getConfiguration())
+                .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value", "mismatchState", "'UNDEF'")));
+        when(mockContext.getAcceptedDataTypes()).thenReturn(List.of(UnDefType.class, StringType.class));
+        when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Mismatch"));
+
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+        profile.onStateUpdateFromHandler(new StringType("ToBeDiscarded"));
+        verify(mockCallback, times(1)).sendUpdate(eq(new StringType("UNDEF")));
+    }
+
+    @Test
+    void testParseStateNonQuotes() {
+        when(mockContext.getAcceptedDataTypes())
+                .thenReturn(List.of(UnDefType.class, OnOffType.class, StringType.class));
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "")));
+
+        StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+        assertEquals(UnDefType.UNDEF, profile.parseState("UNDEF"));
+        assertEquals(new StringType("UNDEF"), profile.parseState("'UNDEF'"));
+        assertEquals(OnOffType.ON, profile.parseState("ON"));
+        assertEquals(new StringType("ON"), profile.parseState("'ON'"));
+    }
+}
diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/ThresholdStateProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/ThresholdStateProfileTest.java
new file mode 100644 (file)
index 0000000..75c6715
--- /dev/null
@@ -0,0 +1,111 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.transform.basicprofiles.internal.profiles;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.math.BigDecimal;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.StateProfile;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.Type;
+
+/**
+ * Basic unit tests for {@link ThresholdStateProfile}.
+ *
+ * @author Christoph Weitkamp - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class ThresholdStateProfileTest {
+
+    public static class ParameterSet {
+        public State state;
+        public State resultingState;
+        public Command command;
+        public Command resultingCommand;
+        public int treshold;
+
+        public ParameterSet(Type source, Type result, int treshold) {
+            this.state = (State) source;
+            this.resultingState = (State) result;
+            this.command = (Command) source;
+            this.resultingCommand = (Command) result;
+            this.treshold = treshold;
+        }
+    }
+
+    public static Collection<Object[]> parameters() {
+        return List.of(new Object[][] { //
+                { new ParameterSet(PercentType.HUNDRED, OnOffType.OFF, 10) }, //
+                { new ParameterSet(new PercentType(BigDecimal.valueOf(25)), OnOffType.OFF, 10) }, //
+                { new ParameterSet(PercentType.ZERO, OnOffType.ON, 10) }, //
+                { new ParameterSet(PercentType.HUNDRED, OnOffType.OFF, 40) }, //
+                { new ParameterSet(new PercentType(BigDecimal.valueOf(25)), OnOffType.ON, 40) }, //
+                { new ParameterSet(PercentType.ZERO, OnOffType.ON, 40) } //
+        });
+    }
+
+    private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
+    private @Mock @NonNullByDefault({}) ProfileContext mockContext;
+
+    @ParameterizedTest
+    @MethodSource("parameters")
+    public void testOnCommandFromHandler(ParameterSet parameterSet) {
+        final StateProfile profile = initProfile(parameterSet.treshold);
+        verifySendCommand(profile, parameterSet.command, parameterSet.resultingCommand);
+    }
+
+    @ParameterizedTest
+    @MethodSource("parameters")
+    public void testOnStateUpdateFromHandler(ParameterSet parameterSet) {
+        final StateProfile profile = initProfile(parameterSet.treshold);
+        verifySendUpdate(profile, parameterSet.state, parameterSet.resultingState);
+    }
+
+    private StateProfile initProfile(int threshold) {
+        when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("threshold", threshold)));
+        return new ThresholdStateProfile(mockCallback, mockContext);
+    }
+
+    private void verifySendCommand(StateProfile profile, Command command, Command result) {
+        reset(mockCallback);
+        profile.onCommandFromHandler(command);
+        verify(mockCallback, times(1)).sendCommand(eq(result));
+    }
+
+    private void verifySendUpdate(StateProfile profile, State state, State result) {
+        reset(mockCallback);
+        profile.onStateUpdateFromHandler(state);
+        verify(mockCallback, times(1)).sendUpdate(eq(result));
+    }
+}
index 39d7e4e2ef197f71a1e01044ff91d4467cf82d09..33b950b9ec09cc57f1359796e950924102e0ccec 100644 (file)
@@ -32,6 +32,7 @@
     <module>org.openhab.io.neeo</module>
     <module>org.openhab.io.openhabcloud</module>
     <!-- transformations -->
+    <module>org.openhab.transform.basicprofiles</module>
     <module>org.openhab.transform.bin2json</module>
     <module>org.openhab.transform.exec</module>
     <module>org.openhab.transform.jinja</module>