[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:
sheilbronn
2026-04-19 14:40:12 +02:00
committed by GitHub
parent b14de1d4b2
commit 4aa2ae0b79
7 changed files with 351 additions and 21 deletions
@@ -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
@@ -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) //
@@ -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));
}
}
@@ -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>
@@ -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>&lt;&gt;</code>, <code>GT</code> or <code>&gt;</code>, <code>GTE</code> or <code>&gt;=</code>, <code>LT</code> or <code>&lt;</code>, and <code>LTE</code> or <code>&lt;=</code>.
profile.config.basic-profiles.state-filter.mismatchState.label = State for Filter Rejects
@@ -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>&lt;&gt;</code>, <code>GT</code> oder <code>&gt;</code>, <code>GTE</code> oder <code>&gt;\=</code>, <code>LT</code> oder <code>&lt;</code>, und <code>LTE</code> oder <code>&lt;\=</code>.
profile.config.basic-profiles.state-filter.mismatchState.label = Abweichender State
@@ -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);