mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-31 13:34:22 +02:00
[basicprofiles] Support DateTime in round profile (#20559)
* [basicprofiles] Support DateTime in round profile Signed-off-by: sheilbronn <36453088+sheilbronn@users.noreply.github.com>
This commit is contained in:
@@ -9,7 +9,7 @@ This bundle provides a list of useful profiles:
|
||||
| [Debounce (Counting) Profile](#debounce-counting-profile) | Counts and skips a number of state changes |
|
||||
| [Debounce (Time) Profile](#debounce-time-profile) | Reduces the frequency of commands or state updates |
|
||||
| [Invert / Negate Profile](#invert-negate-profile) | Inverts or negates a command or state |
|
||||
| [Round Profile](#round-profile) | Reduces the number of decimal places from input data |
|
||||
| [Round Profile](#round-profile) | Rounds numeric and DateTime input data |
|
||||
| [Threshold Profile](#threshold-profile) | Translates numeric input data to `ON` or `OFF` based on a threshold value |
|
||||
| [Time Range Command Profile](#time-range-command-profile) | An enhanced implementation of a follow profile which converts `OnOffType` to a `PercentType` |
|
||||
| [State Filter Profile](#state-filter-profile) | Filters input data using arithmetic comparison conditions |
|
||||
@@ -121,26 +121,41 @@ 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.
|
||||
The Round Profile rounds numeric values and `DateTime` values.
|
||||
|
||||
For `numeric` values, it scales the State to a specific number of decimal places based on the power of ten.
|
||||
It can also limit the precision of the State to a specific number of [significant digits](https://en.wikipedia.org/wiki/Significant_figures).
|
||||
When scaling, a specific [Rounding mode](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/math/RoundingMode.html) may be applied.
|
||||
Source Channels should accept Item Type `Number`.
|
||||
|
||||
For `DateTime` values, rounding is applied to the UTC timestamp using the following scale mapping:
|
||||
|
||||
- `0` = days
|
||||
- `1` = hours
|
||||
- `2` = minutes
|
||||
- `3` = seconds (default)
|
||||
- `4` = milliseconds
|
||||
|
||||
A missing `scale` defaults to `3`(seconds) for a `DateTime` value. `precision` is ignored for a `DateTime` value.
|
||||
|
||||
Source Channels should accept Item Type `Number` or `DateTime`.
|
||||
|
||||
### Round Profile Configuration
|
||||
|
||||
| Configuration Parameter | Type | Description |
|
||||
|-------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------|
|
||||
| `precision` | integer | Limit the number of significant digits in the output (min: 1, max: 16, STEP: 1). |
|
||||
| `scale` | integer | Scale to indicate the resulting number of decimal places (min: -16, max: 16, STEP: 1) . |
|
||||
| `mode` | text | Rounding mode to be used for scaling (e.g. "UP", "DOWN", "CEILING", "FLOOR", "HALF_UP" or "HALF_DOWN" (default: "HALF_UP"). |
|
||||
| Configuration Parameter | Type | Description |
|
||||
|-------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `precision` | integer | Limits the number of significant digits in the output for numeric values (ignored for `DateTime` values.) |
|
||||
| `scale` | integer | For numeric values, indicates the resulting number of decimal places (min: -16, max: 16, STEP: 1). For `DateTime` values, `0` rounds to days, `1` to hours, `2` to minutes, `3` to seconds (default), and `4` to milliseconds. |
|
||||
| `mode` | text | Rounding mode to be used (e.g. `UP`, `DOWN`, `CEILING`, `FLOOR`, `HALF_UP`, or `HALF_DOWN`; default: `HALF_UP`). |
|
||||
|
||||
Either `precision` or `scale` must be given. When both are given, `precision` is applied first, then `scale`.
|
||||
For numeric values, either `precision` or `scale` must be given. When both are given, `precision` is applied first, then `scale`.
|
||||
|
||||
### Round Profile Example
|
||||
|
||||
```java
|
||||
Number roundedNumber { channel="xxx" [profile="basic-profiles:round", scale=0] }
|
||||
Number:Temperature roundedTemperature { channel="xxx" [profile="basic-profiles:round", scale=1] }
|
||||
DateTime roundedTimestamp { channel="xxx" [profile="basic-profiles:round", scale=3] }
|
||||
DateTime roundedTimestampDefault { channel="xxx" [profile="basic-profiles:round"] }
|
||||
```
|
||||
|
||||
## Threshold Profile
|
||||
|
||||
+2
-2
@@ -98,8 +98,8 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
|
||||
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) //
|
||||
.withSupportedItemTypes(CoreItemFactory.NUMBER, CoreItemFactory.DATETIME) //
|
||||
.withSupportedItemTypesOfChannel(CoreItemFactory.NUMBER, CoreItemFactory.DATETIME) //
|
||||
.build();
|
||||
private static final ProfileType PROFILE_TYPE_THRESHOLD = ProfileTypeBuilder.newState(THRESHOLD_UID, "Threshold") //
|
||||
.withSupportedItemTypesOfChannel(CoreItemFactory.DIMMER, CoreItemFactory.NUMBER) //
|
||||
|
||||
+99
-4
@@ -17,9 +17,11 @@ import static org.openhab.transform.basicprofiles.internal.factory.BasicProfiles
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.library.types.DateTimeType;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.thing.profiles.ProfileCallback;
|
||||
@@ -36,8 +38,8 @@ 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}.
|
||||
* Applies rounding with the specified scale and the rounding mode to a {@link QuantityType}, {@link DecimalType},
|
||||
* or {@link DateTimeType} state. Default rounding mode is {@link RoundingMode#HALF_UP}.
|
||||
*
|
||||
* @author Christoph Weitkamp - Initial contribution
|
||||
*/
|
||||
@@ -50,6 +52,8 @@ public class RoundStateProfile implements TimeSeriesProfile {
|
||||
public static final String PARAM_SCALE = "scale";
|
||||
public static final String PARAM_MODE = "mode";
|
||||
|
||||
private static final int DATETIME_DEFAULT_SCALE = 3;
|
||||
|
||||
private final ProfileCallback callback;
|
||||
|
||||
final @Nullable Integer precision;
|
||||
@@ -66,8 +70,6 @@ public class RoundStateProfile implements TimeSeriesProfile {
|
||||
Integer localScale = null;
|
||||
if (config.scale != null) {
|
||||
localScale = config.scale;
|
||||
} else {
|
||||
logger.error("Parameter 'scale' is not of type String or Number.");
|
||||
}
|
||||
|
||||
Integer localPrecision = null;
|
||||
@@ -136,6 +138,8 @@ public class RoundStateProfile implements TimeSeriesProfile {
|
||||
if (state instanceof QuantityType<?> qtState) {
|
||||
BigDecimal rounded = roundNumber(qtState.toBigDecimal());
|
||||
return new QuantityType<>(rounded, qtState.getUnit());
|
||||
} else if (state instanceof DateTimeType dtState) {
|
||||
return roundDateTime(dtState);
|
||||
} else if (state instanceof DecimalType dtState) {
|
||||
BigDecimal rounded = roundNumber(dtState.toBigDecimal());
|
||||
return new DecimalType(rounded);
|
||||
@@ -158,4 +162,95 @@ public class RoundStateProfile implements TimeSeriesProfile {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private DateTimeType roundDateTime(DateTimeType value) {
|
||||
final int configuredScale = scale != null ? scale.intValue() : DATETIME_DEFAULT_SCALE;
|
||||
|
||||
if (precision != null) {
|
||||
logger.debug("Ignoring precision '{}' for DateTime value rounding.", precision);
|
||||
}
|
||||
|
||||
final Long unitNanos = getDateTimeUnitNanos(configuredScale);
|
||||
if (unitNanos == null) {
|
||||
logger.warn(
|
||||
"Scale '{}' is not supported for DateTime values. Supported scales are 0 (days), 1 (hours), 2 (minutes), 3 (seconds), and 4 (milliseconds). Returning original state.",
|
||||
configuredScale);
|
||||
return value;
|
||||
}
|
||||
|
||||
return new DateTimeType(roundInstant(value.getInstant(), unitNanos.longValue()));
|
||||
}
|
||||
|
||||
private Instant roundInstant(Instant value, long unitNanos) {
|
||||
try {
|
||||
long epochNanos = Math.addExact(Math.multiplyExact(value.getEpochSecond(), 1_000_000_000L),
|
||||
value.getNano());
|
||||
long floor = Math.multiplyExact(Math.floorDiv(epochNanos, unitNanos), unitNanos);
|
||||
if (floor == epochNanos) {
|
||||
return instantFromEpochNanos(floor);
|
||||
}
|
||||
long ceiling = Math.addExact(floor, unitNanos);
|
||||
return instantFromEpochNanos(selectRoundedEpochNanos(epochNanos, floor, ceiling, unitNanos));
|
||||
} catch (ArithmeticException e) {
|
||||
if (roundingMode == RoundingMode.UNNECESSARY) {
|
||||
throw e;
|
||||
}
|
||||
logger.warn(
|
||||
"Failed to round DateTime value '{}' at scale '{}' due to arithmetic overflow. Returning original value.",
|
||||
value, scale, e);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private long selectRoundedEpochNanos(long value, long floor, long ceiling, long unitNanos) {
|
||||
return switch (roundingMode) {
|
||||
case UP -> value >= 0 ? ceiling : floor;
|
||||
case DOWN -> value >= 0 ? floor : ceiling;
|
||||
case CEILING -> ceiling;
|
||||
case FLOOR -> floor;
|
||||
case HALF_UP -> selectHalfRoundedEpochNanos(value, floor, ceiling, true);
|
||||
case HALF_DOWN -> selectHalfRoundedEpochNanos(value, floor, ceiling, false);
|
||||
case HALF_EVEN -> selectHalfEvenRoundedEpochNanos(value, floor, ceiling, unitNanos);
|
||||
case UNNECESSARY -> throw new ArithmeticException("Rounding necessary");
|
||||
};
|
||||
}
|
||||
|
||||
private long selectHalfRoundedEpochNanos(long value, long floor, long ceiling, boolean tiesAwayFromZero) {
|
||||
int distanceComparison = Long.compare(Math.subtractExact(value, floor), Math.subtractExact(ceiling, value));
|
||||
if (distanceComparison < 0) {
|
||||
return floor;
|
||||
} else if (distanceComparison > 0) {
|
||||
return ceiling;
|
||||
}
|
||||
if (tiesAwayFromZero) {
|
||||
return value >= 0 ? ceiling : floor;
|
||||
}
|
||||
return value >= 0 ? floor : ceiling;
|
||||
}
|
||||
|
||||
private long selectHalfEvenRoundedEpochNanos(long value, long floor, long ceiling, long unitNanos) {
|
||||
int distanceComparison = Long.compare(Math.subtractExact(value, floor), Math.subtractExact(ceiling, value));
|
||||
if (distanceComparison < 0) {
|
||||
return floor;
|
||||
} else if (distanceComparison > 0) {
|
||||
return ceiling;
|
||||
}
|
||||
return Math.floorMod(Math.floorDiv(floor, unitNanos), 2) == 0 ? floor : ceiling;
|
||||
}
|
||||
|
||||
private @Nullable Long getDateTimeUnitNanos(int configuredScale) {
|
||||
return switch (configuredScale) {
|
||||
case 0 -> 86_400_000_000_000L;
|
||||
case 1 -> 3_600_000_000_000L;
|
||||
case 2 -> 60_000_000_000L;
|
||||
case 3 -> 1_000_000_000L;
|
||||
case 4 -> 1_000_000L;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Instant instantFromEpochNanos(long epochNanos) {
|
||||
return Instant.ofEpochSecond(Math.floorDiv(epochNanos, 1_000_000_000L),
|
||||
Math.floorMod(epochNanos, 1_000_000_000L));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -7,11 +7,14 @@
|
||||
<config-description uri="profile:basic-profiles:round">
|
||||
<parameter name="precision" type="integer" min="1" step="1" required="false">
|
||||
<label>Precision</label>
|
||||
<description>Optional. Number of significant digits to round to before applying scale.</description>
|
||||
<description>Optional. Number of significant digits to round to before applying scale for numeric values. Ignored for
|
||||
DateTime values.</description>
|
||||
</parameter>
|
||||
<parameter name="scale" type="integer" min="-16" max="16" step="1" required="true">
|
||||
<parameter name="scale" type="integer" min="-16" max="16" step="1" required="false">
|
||||
<label>Scale</label>
|
||||
<description>Scale to indicate the resulting number of decimal places.</description>
|
||||
<description>For numeric values, scale indicates the resulting number of decimal places. For DateTime values, the UTC
|
||||
timestamp is rounded with scale 0 for days, 1 for hours, 2 for minutes, 3 for seconds, and 4 for milliseconds. If
|
||||
omitted for DateTime values, scale 3 is used.</description>
|
||||
</parameter>
|
||||
<parameter name="mode" type="text">
|
||||
<label>Rounding Mode</label>
|
||||
|
||||
+2
-2
@@ -43,9 +43,9 @@ profile.config.basic-profiles.round.mode.option.FLOOR = FLOOR
|
||||
profile.config.basic-profiles.round.mode.option.HALF_UP = HALF_UP
|
||||
profile.config.basic-profiles.round.mode.option.HALF_DOWN = HALF_DOWN
|
||||
profile.config.basic-profiles.round.precision.label = Precision
|
||||
profile.config.basic-profiles.round.precision.description = Optional. Number of significant digits to round to before applying scale.
|
||||
profile.config.basic-profiles.round.precision.description = Optional. Number of significant digits to round to before applying scale for numeric values. Ignored for DateTime values.
|
||||
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.scale.description = For numeric values, scale indicates the resulting number of decimal places. For DateTime values, the UTC timestamp is rounded with scale 0 for days, 1 for hours, 2 for minutes, 3 for seconds, and 4 for milliseconds. If omitted for DateTime values, scale 3 is used.
|
||||
profile.config.basic-profiles.state-filter.conditions.label = Conditions
|
||||
profile.config.basic-profiles.state-filter.conditions.description = List of expressions in the format [ITEM_NAME] OPERATOR VALUE_OR_ITEM_NAME, e.g., "MyItem == OFF". Use quotes around VALUE_OR_ITEM_NAME to perform string comparison, e.g., "'OFF'". VALUE can be a DecimalType or a QuantityType with a unit. When ITEM_NAME is omitted, the comparisons are done against the input state from the channel. <br /><br /> Multiple conditions can be specified by writing each expression on a separate line, or when specified on the same line, separated by the separator character (default: ","). All the conditions are ANDed to determine the result. <br /><br /> The following operators are supported: <code>EQ</code> or <code>==</code>, <code>NE</code>, <code>!=</code>, or <code><></code>, <code>GT</code> or <code>></code>, <code>GTE</code> or <code>>=</code>, <code>LT</code> or <code><</code>, and <code>LTE</code> or <code><=</code>.
|
||||
profile.config.basic-profiles.state-filter.mismatchState.label = State for Filter Rejects
|
||||
|
||||
+3
-1
@@ -43,7 +43,9 @@ profile.config.basic-profiles.round.mode.option.FLOOR = FLOOR
|
||||
profile.config.basic-profiles.round.mode.option.HALF_UP = HALF_UP
|
||||
profile.config.basic-profiles.round.mode.option.HALF_DOWN = HALF_DOWN
|
||||
profile.config.basic-profiles.round.scale.label = Skalierung
|
||||
profile.config.basic-profiles.round.scale.description = Skalierung, die auf den Wert angewendet werden soll.
|
||||
profile.config.basic-profiles.round.scale.description = Anzahl der Nachkommastellen fuer numerische Werte. Fuer DateTime-Werte wird der UTC-Zeitstempel mit 0 fuer Tage, 1 fuer Stunden, 2 fuer Minuten, 3 fuer Sekunden und 4 fuer Millisekunden gerundet. Wenn keine Skalierung gesetzt ist, wird fuer DateTime-Werte 3 verwendet.
|
||||
profile.config.basic-profiles.round.precision.label = Genauigkeit
|
||||
profile.config.basic-profiles.round.precision.description = Anzahl signifikanter Stellen fuer numerische Werte (vor Anwendung der Skalierung). Ignoriert bei DateTime-Werten. Wenn gesetzt, wird dies nur auf Debug-Ebene protokolliert.
|
||||
profile.config.basic-profiles.state-filter.conditions.label = Bedingungen
|
||||
profile.config.basic-profiles.state-filter.conditions.description = Eine Liste von Bedingungen im Format [ITEM_NAME] OPERATOR VALUE_OR_ITEM_NAME, z.B. "MyItem \=\= OFF". Verwenden Sie Anführungszeichen um VALUE_OR_ITEM_NAME um einen Textvergleich durchzuführen, z.B. "OFF". VALUE kann ein DecimalType oder ein QuantityType mit einer Einheit sein. Wenn ITEM_NAME weggelassen wird, werden die Vergleiche mit dem Eingangszustand des Channels durchgeführt. <br /><br /> Mehrere Bedingungen können durch das Schreiben jedes Ausdrucks in einer separaten Zeile angegeben werden oder, wenn in der gleichen Zeile angegeben, durch das Trennzeichen getrennt (Standard\: ","). Alle Bedingungen werden als UND-Verknüpfung verarbeitet, um das Ergebnis zu bestimmen. <br /><br /> Die folgenden Operatoren werden unterstützt\: <code>EQ</code> oder <code>\=\=</code>, <code>NE</code>, <code>\!</code>, oder <code><></code>, <code>GT</code> oder <code>></code>, <code>GTE</code> oder <code>>\=</code>, <code>LT</code> oder <code><</code>, und <code>LTE</code> oder <code><\=</code>.
|
||||
profile.config.basic-profiles.state-filter.mismatchState.label = Abweichender State
|
||||
|
||||
+215
@@ -19,6 +19,8 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import javax.measure.Unit;
|
||||
import javax.measure.quantity.Temperature;
|
||||
@@ -29,6 +31,7 @@ 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.DateTimeType;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.ImperialUnits;
|
||||
@@ -164,6 +167,96 @@ public class RoundStateProfileTest {
|
||||
assertThat(qtResult.getUnit(), is(SIUnits.CELSIUS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeTypeOnCommandFromItemForHours() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile roundProfile = createProfile(callback, null, 1);
|
||||
|
||||
Command cmd = new DateTimeType(ZonedDateTime.parse("2026-04-12T10:31:30+02:00"));
|
||||
roundProfile.onCommandFromItem(cmd);
|
||||
|
||||
ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
|
||||
verify(callback, times(1)).handleCommand(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
assertEquals(Instant.parse("2026-04-12T09:00:00Z"), result.getInstant());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeTypeOnStateUpdateFromHandlerForMinutes() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile roundProfile = createProfile(callback, null, 2);
|
||||
|
||||
State state = new DateTimeType(ZonedDateTime.parse("2026-04-12T10:29:31+02:00"));
|
||||
roundProfile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
assertEquals(Instant.parse("2026-04-12T08:30:00Z"), result.getInstant());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeTypeOnCommandFromHandlerForDays() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile roundProfile = createProfile(callback, null, 0);
|
||||
|
||||
Command cmd = new DateTimeType(ZonedDateTime.parse("2026-04-12T15:00:00+02:00"));
|
||||
roundProfile.onCommandFromHandler(cmd);
|
||||
|
||||
ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
|
||||
verify(callback, times(1)).sendCommand(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
assertEquals(Instant.parse("2026-04-13T00:00:00Z"), result.getInstant());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeTypeOnStateUpdateFromHandlerForMilliseconds() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile roundProfile = createProfile(callback, null, 4);
|
||||
|
||||
State state = new DateTimeType(ZonedDateTime.parse("2026-04-12T10:29:30.123600+02:00"));
|
||||
roundProfile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
assertEquals(Instant.parse("2026-04-12T08:29:30.124Z"), result.getInstant());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeTypeDefaultsToSecondsWhenScaleMissing() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile roundProfile = createProfile(callback, null, null);
|
||||
|
||||
State state = new DateTimeType(ZonedDateTime.parse("2026-04-12T10:29:30.600+02:00"));
|
||||
roundProfile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
assertEquals(Instant.parse("2026-04-12T08:29:31Z"), result.getInstant());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeTypeIgnoresPrecision() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile roundProfile = createProfile(callback, 5, 3);
|
||||
|
||||
State state = new DateTimeType(ZonedDateTime.parse("2026-04-12T10:29:30.600+02:00"));
|
||||
roundProfile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
assertEquals(Instant.parse("2026-04-12T08:29:31Z"), result.getInstant());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDecimalTypeOnStateUpdateFromHandler() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
@@ -220,6 +313,128 @@ public class RoundStateProfileTest {
|
||||
assertThat(dtResult.doubleValue(), is(23.3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeTimeSeriesFromHandler() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile roundProfile = createProfile(callback, null, 3);
|
||||
|
||||
TimeSeries ts = new TimeSeries(Policy.ADD);
|
||||
Instant now = Instant.now();
|
||||
ts.add(now, new DateTimeType(ZonedDateTime.parse("2026-04-12T10:29:30.600+02:00")));
|
||||
|
||||
roundProfile.onTimeSeriesFromHandler(ts);
|
||||
|
||||
ArgumentCaptor<TimeSeries> capture = ArgumentCaptor.forClass(TimeSeries.class);
|
||||
verify(callback, times(1)).sendTimeSeries(capture.capture());
|
||||
|
||||
TimeSeries result = capture.getValue();
|
||||
assertEquals(ts.getStates().count(), result.getStates().count());
|
||||
Entry entry = result.getStates().findFirst().get();
|
||||
assertNotNull(entry);
|
||||
assertEquals(now, entry.timestamp());
|
||||
DateTimeType dtResult = (DateTimeType) entry.state();
|
||||
assertEquals(Instant.parse("2026-04-12T08:29:31Z"), dtResult.getInstant());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeFloorToMinutes() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile profile = createProfile(callback, null, 2, RoundingMode.FLOOR.name());
|
||||
|
||||
ZonedDateTime input = ZonedDateTime.of(2025, 9, 27, 14, 16, 28, 500_000_000, ZoneId.of("Europe/Berlin"));
|
||||
State state = new DateTimeType(input);
|
||||
profile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
ZonedDateTime expected = ZonedDateTime.of(2025, 9, 27, 14, 16, 0, 0, ZoneId.of("Europe/Berlin"));
|
||||
assertThat(result.getInstant(), is(expected.toInstant()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeCeilingToHours() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile profile = createProfile(callback, null, 1, RoundingMode.CEILING.name());
|
||||
|
||||
ZonedDateTime input = ZonedDateTime.of(2025, 9, 27, 14, 16, 28, 0, ZoneId.of("Europe/Berlin"));
|
||||
State state = new DateTimeType(input);
|
||||
profile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
ZonedDateTime expected = ZonedDateTime.of(2025, 9, 27, 15, 0, 0, 0, ZoneId.of("Europe/Berlin"));
|
||||
assertThat(result.getInstant(), is(expected.toInstant()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeCeilingToHoursWhenAlreadyOnBoundary() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile profile = createProfile(callback, null, 1, RoundingMode.CEILING.name());
|
||||
|
||||
ZonedDateTime input = ZonedDateTime.of(2025, 9, 27, 14, 0, 0, 0, ZoneId.of("Europe/Berlin"));
|
||||
State state = new DateTimeType(input);
|
||||
profile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
assertThat(result.getInstant(), is(input.toInstant()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeHalfUpToMinutesRoundsUp() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile profile = createProfile(callback, null, 2, RoundingMode.HALF_UP.name());
|
||||
|
||||
ZonedDateTime input = ZonedDateTime.of(2025, 9, 27, 14, 16, 30, 0, ZoneId.of("UTC"));
|
||||
State state = new DateTimeType(input);
|
||||
profile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
ZonedDateTime expected = ZonedDateTime.of(2025, 9, 27, 14, 17, 0, 0, ZoneId.of("UTC"));
|
||||
assertThat(result.getInstant(), is(expected.toInstant()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeHalfUpToMinutesRoundsDown() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile profile = createProfile(callback, null, 2, RoundingMode.HALF_UP.name());
|
||||
|
||||
ZonedDateTime input = ZonedDateTime.of(2025, 9, 27, 14, 16, 29, 999_000_000, ZoneId.of("UTC"));
|
||||
State state = new DateTimeType(input);
|
||||
profile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
DateTimeType result = (DateTimeType) capture.getValue();
|
||||
ZonedDateTime expected = ZonedDateTime.of(2025, 9, 27, 14, 16, 0, 0, ZoneId.of("UTC"));
|
||||
assertThat(result.getInstant(), is(expected.toInstant()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDateTimeInvalidScaleReturnsOriginal() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
RoundStateProfile profile = createProfile(callback, null, 5);
|
||||
|
||||
ZonedDateTime input = ZonedDateTime.of(2025, 9, 27, 14, 16, 28, 0, ZoneId.of("UTC"));
|
||||
State state = new DateTimeType(input);
|
||||
profile.onStateUpdateFromHandler(state);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
|
||||
assertThat(capture.getValue(), is(state));
|
||||
}
|
||||
|
||||
private RoundStateProfile createProfile(ProfileCallback callback, @Nullable Integer precision,
|
||||
@Nullable Integer scale) {
|
||||
return createProfile(callback, precision, scale, null);
|
||||
|
||||
Reference in New Issue
Block a user