Improve time zone handling (#18384)

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
This commit is contained in:
Jacob Laursen
2025-03-22 22:19:55 +01:00
committed by GitHub
parent 8ef6b6a964
commit 96c38dfb11
9 changed files with 123 additions and 44 deletions
@@ -17,6 +17,7 @@ These are the available configuration parameters:
- `host` Hostname/IP of the air unit (automatically set by discovery service)
- `refreshInterval` Time (in seconds) between monitoring requests to the air unit. Smaller values mean more network load, typically set between a few seconds and a minute. Defaults to 10 seconds.
- `updateUnchangedValuesEveryMillis` Minimum time between state updates sent to the event bus for a particular channel when the state of the channel didn't change. This should avoid spamming the event bus with unnecessary updates. When set to 0, all channel state are updated every time the air unit requests are sent (see refresh interval). When set to a non zero value, unchanged values are only reported after the configured timespan has passed. Changed values are always sent to the event bus. Defaults to 60.000 (one minute), so updates are sent every minute or if the state of the channel changes.
- `timeZone` Time zone of the air unit. Leave empty for defaulting to openHAB time zone.
## Channels
@@ -19,7 +19,7 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.Instant;
import java.time.ZonedDateTime;
import javax.measure.quantity.Dimensionless;
@@ -27,6 +27,7 @@ import javax.measure.quantity.Frequency;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
@@ -50,9 +51,11 @@ import org.openhab.core.types.Command;
public class DanfossAirUnit {
private final CommunicationController communicationController;
private final TimeZoneProvider timeZoneProvider;
public DanfossAirUnit(CommunicationController communicationController) {
public DanfossAirUnit(CommunicationController communicationController, TimeZoneProvider timeZoneProvider) {
this.communicationController = communicationController;
this.timeZoneProvider = timeZoneProvider;
}
private boolean getBoolean(byte[] operation, byte[] register) throws IOException {
@@ -94,13 +97,13 @@ public class DanfossAirUnit {
return temp;
}
private ZonedDateTime getTimestamp(byte[] operation, byte[] register)
private Instant getTimestamp(byte[] operation, byte[] register)
throws IOException, UnexpectedResponseValueException {
byte[] result = communicationController.sendRobustRequest(operation, register);
return asZonedDateTime(result);
return asInstant(result);
}
private ZonedDateTime asZonedDateTime(byte[] data) throws UnexpectedResponseValueException {
private Instant asInstant(byte[] data) throws UnexpectedResponseValueException {
int second = data[0];
int minute = data[1];
int hour = data[2] & 0x1f;
@@ -108,7 +111,8 @@ public class DanfossAirUnit {
int month = data[4];
int year = data[5] + 2000;
try {
return ZonedDateTime.of(year, month, day, hour, minute, second, 0, ZoneId.systemDefault());
return ZonedDateTime.of(year, month, day, hour, minute, second, 0, timeZoneProvider.getTimeZone())
.toInstant();
} catch (DateTimeException e) {
String msg = String.format("Ignoring invalid timestamp %s.%s.%s %s:%s:%s", day, month, year, hour, minute,
second);
@@ -227,7 +231,7 @@ public class DanfossAirUnit {
}
public DateTimeType getCurrentTime() throws IOException, UnexpectedResponseValueException {
ZonedDateTime timestamp = getTimestamp(REGISTER_1_READ, CURRENT_TIME);
Instant timestamp = getTimestamp(REGISTER_1_READ, CURRENT_TIME);
return new DateTimeType(timestamp);
}
@@ -29,4 +29,6 @@ public class DanfossAirUnitConfiguration {
public int refreshInterval = 10;
public long updateUnchangedValuesEveryMillis = 60000;
public String timeZone = "";
}
@@ -19,12 +19,15 @@ import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.danfossairunit.internal.handler.DanfossAirUnitHandler;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link DanfossAirUnitHandlerFactory} is responsible for creating things and thing
@@ -39,6 +42,13 @@ public class DanfossAirUnitHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_AIRUNIT);
private final TimeZoneProvider timeZoneProvider;
@Activate
public DanfossAirUnitHandlerFactory(final @Reference TimeZoneProvider timeZoneProvider) {
this.timeZoneProvider = timeZoneProvider;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
@@ -48,8 +58,8 @@ public class DanfossAirUnitHandlerFactory extends BaseThingHandlerFactory {
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(THING_TYPE_AIRUNIT)) {
return new DanfossAirUnitHandler(thing);
if (THING_TYPE_AIRUNIT.equals(thingTypeUID)) {
return new DanfossAirUnitHandler(thing, timeZoneProvider);
}
return null;
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2010-2025 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.binding.danfossairunit.internal;
import java.time.ZoneId;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.i18n.TimeZoneProvider;
/**
* Provider for returning a fixed time zone.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class FixedTimeZoneProvider implements TimeZoneProvider {
private final ZoneId timeZone;
private FixedTimeZoneProvider(ZoneId timeZone) {
this.timeZone = timeZone;
}
public static FixedTimeZoneProvider of(ZoneId timeZone) {
return new FixedTimeZoneProvider(timeZone);
}
public ZoneId getTimeZone() {
return timeZone;
}
}
@@ -15,6 +15,8 @@ package org.openhab.binding.danfossairunit.internal.handler;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
@@ -27,8 +29,10 @@ import org.openhab.binding.danfossairunit.internal.DanfossAirUnit;
import org.openhab.binding.danfossairunit.internal.DanfossAirUnitCommunicationController;
import org.openhab.binding.danfossairunit.internal.DanfossAirUnitConfiguration;
import org.openhab.binding.danfossairunit.internal.DanfossAirUnitWriteAccessor;
import org.openhab.binding.danfossairunit.internal.FixedTimeZoneProvider;
import org.openhab.binding.danfossairunit.internal.UnexpectedResponseValueException;
import org.openhab.binding.danfossairunit.internal.ValueCache;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
@@ -53,7 +57,10 @@ import org.slf4j.LoggerFactory;
public class DanfossAirUnitHandler extends BaseThingHandler {
private static final int POLLING_INTERVAL_SECONDS = 5;
private final Logger logger = LoggerFactory.getLogger(DanfossAirUnitHandler.class);
private final TimeZoneProvider timeZoneProvider;
private @NonNullByDefault({}) DanfossAirUnitConfiguration config;
private @Nullable ValueCache valueCache;
private @Nullable ScheduledFuture<?> pollingJob;
@@ -61,8 +68,9 @@ public class DanfossAirUnitHandler extends BaseThingHandler {
private @Nullable DanfossAirUnit airUnit;
private boolean propertiesInitializedSuccessfully = false;
public DanfossAirUnitHandler(Thing thing) {
public DanfossAirUnitHandler(final Thing thing, final TimeZoneProvider timeZoneProvider) {
super(thing);
this.timeZoneProvider = timeZoneProvider;
}
@Override
@@ -100,12 +108,16 @@ public class DanfossAirUnitHandler extends BaseThingHandler {
var localCommunicationController = new DanfossAirUnitCommunicationController(
InetAddress.getByName(config.host));
this.communicationController = localCommunicationController;
this.airUnit = new DanfossAirUnit(localCommunicationController);
TimeZoneProvider timeZoneProvider = config.timeZone.isBlank() ? this.timeZoneProvider
: FixedTimeZoneProvider.of(ZoneId.of(config.timeZone));
airUnit = new DanfossAirUnit(localCommunicationController, timeZoneProvider);
startPolling();
} catch (UnknownHostException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
"@text/offline.communication-error.unknown-host [\"" + config.host + "\"]");
return;
} catch (DateTimeException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
}
}
@@ -14,6 +14,8 @@ thing-type.config.danfossairunit.airunit.host.label = Host
thing-type.config.danfossairunit.airunit.host.description = Host name or IP address of the Danfoss Air CCM
thing-type.config.danfossairunit.airunit.refreshInterval.label = Refresh Interval
thing-type.config.danfossairunit.airunit.refreshInterval.description = Seconds between fetching values from the air unit. Default is 10.
thing-type.config.danfossairunit.airunit.timeZone.label = Time Zone
thing-type.config.danfossairunit.airunit.timeZone.description = Time zone of the air unit. Leave empty for defaulting to openHAB time zone.
thing-type.config.danfossairunit.airunit.updateUnchangedValuesEveryMillis.label = Interval for Updating Unchanged Values
thing-type.config.danfossairunit.airunit.updateUnchangedValuesEveryMillis.description = Interval to update unchanged values (to the event bus) in milliseconds. A value of 0 means that every value (received via polling from the air unit) is updated to the event bus, unchanged or not.
@@ -44,6 +44,11 @@
value (received via polling from the air unit) is updated to the event bus, unchanged or not.</description>
<advanced>true</advanced>
</parameter>
<parameter name="timeZone" type="text" required="false">
<label>Time Zone</label>
<description>Time zone of the air unit. Leave empty for defaulting to openHAB time zone.</description>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
@@ -25,6 +25,7 @@ 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.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
@@ -43,28 +44,29 @@ import org.openhab.core.test.java.JavaTest;
public class DanfossAirUnitTest extends JavaTest {
private @NonNullByDefault({}) @Mock CommunicationController communicationController;
private @NonNullByDefault({}) @Mock TimeZoneProvider timeZoneProvider;
@Test
public void getUnitNameIsReturned() throws IOException {
byte[] response = new byte[] { (byte) 0x05, (byte) 'w', (byte) '2', (byte) '/', (byte) 'a', (byte) '2' };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, UNIT_NAME)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_1_READ, UNIT_NAME)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals("w2/a2", airUnit.getUnitName());
}
@Test
public void getHumidityWhenNearestNeighborIsBelowRoundsDown() throws IOException {
byte[] response = new byte[] { (byte) 0x64 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, HUMIDITY)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_1_READ, HUMIDITY)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(new QuantityType<>("39.2 %"), airUnit.getHumidity());
}
@Test
public void getHumidityWhenNearestNeighborIsAboveRoundsUp() throws IOException {
byte[] response = new byte[] { (byte) 0x67 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, HUMIDITY)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_1_READ, HUMIDITY)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(new QuantityType<>("40.4 %"), airUnit.getHumidity());
}
@@ -72,8 +74,8 @@ public class DanfossAirUnitTest extends JavaTest {
public void getSupplyTemperatureWhenNearestNeighborIsBelowRoundsDown()
throws IOException, UnexpectedResponseValueException {
byte[] response = new byte[] { (byte) 0x09, (byte) 0xf0 }; // 0x09f0 = 2544 => 25.44
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(new QuantityType<>("25.4 °C"), airUnit.getSupplyTemperature());
}
@@ -81,24 +83,24 @@ public class DanfossAirUnitTest extends JavaTest {
public void getSupplyTemperatureWhenBothNeighborsAreEquidistantRoundsUp()
throws IOException, UnexpectedResponseValueException {
byte[] response = new byte[] { (byte) 0x09, (byte) 0xf1 }; // 0x09f1 = 2545 => 25.45
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(new QuantityType<>("25.5 °C"), airUnit.getSupplyTemperature());
}
@Test
public void getSupplyTemperatureWhenBelowValidRangeThrows() throws IOException {
byte[] response = new byte[] { (byte) 0x94, (byte) 0xf8 }; // 0x94f8 = -27400 => -274
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getSupplyTemperature());
}
@Test
public void getSupplyTemperatureWhenAboveValidRangeThrows() throws IOException {
byte[] response = new byte[] { (byte) 0x27, (byte) 0x11 }; // 0x2711 = 10001 => 100,01
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getSupplyTemperature());
}
@@ -106,9 +108,10 @@ public class DanfossAirUnitTest extends JavaTest {
public void getCurrentTimeWhenWellFormattedIsParsed() throws IOException, UnexpectedResponseValueException {
byte[] response = new byte[] { (byte) 0x03, (byte) 0x02, (byte) 0x0f, (byte) 0x1d, (byte) 0x08, (byte) 0x15 }; // 29.08.21
// 15:02:03
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, CURRENT_TIME)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(new DateTimeType(ZonedDateTime.of(2021, 8, 29, 15, 2, 3, 0, ZoneId.systemDefault())),
when(communicationController.sendRobustRequest(REGISTER_1_READ, CURRENT_TIME)).thenReturn(response);
when(timeZoneProvider.getTimeZone()).thenReturn(ZoneId.of("Europe/Copenhagen"));
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(new DateTimeType(ZonedDateTime.of(2021, 8, 29, 15, 2, 3, 0, ZoneId.of("Europe/Copenhagen"))),
airUnit.getCurrentTime());
}
@@ -116,16 +119,17 @@ public class DanfossAirUnitTest extends JavaTest {
public void getCurrentTimeWhenInvalidDateThrows() throws IOException {
byte[] response = new byte[] { (byte) 0x03, (byte) 0x02, (byte) 0x0f, (byte) 0x20, (byte) 0x08, (byte) 0x15 }; // 32.08.21
// 15:02:03
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, CURRENT_TIME)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_1_READ, CURRENT_TIME)).thenReturn(response);
when(timeZoneProvider.getTimeZone()).thenReturn(ZoneId.of("Europe/Copenhagen"));
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getCurrentTime());
}
@Test
public void getBoostWhenZeroIsOff() throws IOException {
byte[] response = new byte[] { (byte) 0x00 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, BOOST)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_1_READ, BOOST)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(OnOffType.OFF, airUnit.getBoost());
}
@@ -133,7 +137,7 @@ public class DanfossAirUnitTest extends JavaTest {
public void getBoostWhenNonZeroIsOn() throws IOException {
byte[] response = new byte[] { (byte) 0x66 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, BOOST)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(OnOffType.ON, airUnit.getBoost());
}
@@ -141,34 +145,32 @@ public class DanfossAirUnitTest extends JavaTest {
public void getManualFanStepWhenWithinValidRangeIsConvertedIntoPercent()
throws IOException, UnexpectedResponseValueException {
byte[] response = new byte[] { (byte) 0x05 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP))
.thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(new PercentType(50), airUnit.getManualFanStep());
}
@Test
public void getSupplyFanSpeedIsReturnedAsRPM() throws IOException {
byte[] response = new byte[] { (byte) 0x04, (byte) 0xda };
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_FAN_SPEED)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_FAN_SPEED)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(new QuantityType<>(1242, Units.RPM), airUnit.getSupplyFanSpeed());
}
@Test
public void getManualFanStepWhenOutOfRangeThrows() throws IOException {
byte[] response = new byte[] { (byte) 0x0b };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP))
.thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getManualFanStep());
}
@Test
public void getFilterLifeWhenNearestNeighborIsBelowRoundsDown() throws IOException {
byte[] response = new byte[] { (byte) 0xf0 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, FILTER_LIFE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
when(communicationController.sendRobustRequest(REGISTER_1_READ, FILTER_LIFE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController, timeZoneProvider);
assertEquals(new DecimalType("94.1"), airUnit.getFilterLife());
}
}