]> git.basschouten.com Git - openhab-addons.git/commitdiff
[danfossairunit] Fix network reliability issues and setting of all channel values...
authorjlaur <jacob-github@vindvejr.dk>
Mon, 27 Sep 2021 05:58:10 +0000 (07:58 +0200)
committerGitHub <noreply@github.com>
Mon, 27 Sep 2021 05:58:10 +0000 (07:58 +0200)
* Fix Potential null pointer accesses
* Added constants for TCP port and poll interval in seconds.
* Extract interface from DanfossAirUnitCommunicationController.
* Remove unused constants which seems to be left-overs from skeleton.
* Added constant for discovery timeout value for readability.
* Created handler subfolder for DanfossAirUnitHandler (like discovery) for consistency with other bindings.
* Handle lost connection gracefully without updating all channels to zero.
* Fix infinitly blocking network calls preventing proper error handling.
* Fix thing status being reset to ONLINE after failing to update all channels.
* Fix error handling when receiving invalid manual fan step.

Fixes #11167
Fixes #11188

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/CommunicationController.java [new file with mode: 0644]
bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnit.java
bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnitBindingConstants.java
bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnitCommunicationController.java
bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnitHandler.java [deleted file]
bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/ValueCache.java
bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/discovery/DanfossAirUnitDiscoveryService.java
bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/handler/DanfossAirUnitHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.danfossairunit/src/test/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnitTest.java [new file with mode: 0644]

diff --git a/bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/CommunicationController.java b/bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/CommunicationController.java
new file mode 100644 (file)
index 0000000..6d08737
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2021 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.io.IOException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * This interface defines a communication controller that can be used to send requests to the Danfoss Air Unit.
+ * 
+ * @author Jacob Laursen - Refactoring, bugfixes and enhancements
+ */
+@NonNullByDefault
+public interface CommunicationController {
+    void connect() throws IOException;
+
+    void disconnect();
+
+    byte[] sendRobustRequest(byte[] operation, byte[] register) throws IOException;
+
+    byte[] sendRobustRequest(byte[] operation, byte[] register, byte[] value) throws IOException;
+}
index 1fbb4c770c1d6b0baeca7e74d1dd7f8402098f63..0e3646281ed171a23edc637202f78d07bc70657d 100644 (file)
@@ -17,7 +17,6 @@ import static org.openhab.binding.danfossairunit.internal.Commands.*;
 import java.io.IOException;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
-import java.net.InetAddress;
 import java.nio.charset.StandardCharsets;
 import java.time.DateTimeException;
 import java.time.ZoneId;
@@ -43,30 +42,22 @@ import org.openhab.core.types.Command;
  * 
  * @author Ralf Duckstein - Initial contribution
  * @author Robert Bach - heavy refactorings
+ * @author Jacob Laursen - Refactoring, bugfixes and enhancements
  */
 
-@SuppressWarnings("SameParameterValue")
 @NonNullByDefault
 public class DanfossAirUnit {
 
-    private final DanfossAirUnitCommunicationController communicationController;
+    private final CommunicationController communicationController;
 
-    public DanfossAirUnit(InetAddress inetAddr, int port) {
-        this.communicationController = new DanfossAirUnitCommunicationController(inetAddr, port);
-    }
-
-    public void cleanUp() {
-        this.communicationController.disconnect();
+    public DanfossAirUnit(CommunicationController communicationController) {
+        this.communicationController = communicationController;
     }
 
     private boolean getBoolean(byte[] operation, byte[] register) throws IOException {
         return communicationController.sendRobustRequest(operation, register)[0] != 0;
     }
 
-    private void setSetting(byte[] register, boolean value) throws IOException {
-        setSetting(register, value ? (byte) 1 : (byte) 0);
-    }
-
     private short getWord(byte[] operation, byte[] register) throws IOException {
         byte[] resultBytes = communicationController.sendRobustRequest(operation, register);
         return (short) ((resultBytes[0] << 8) | (resultBytes[1] & 0xFF));
@@ -87,14 +78,6 @@ public class DanfossAirUnit {
         communicationController.sendRobustRequest(operation, register, valueArray);
     }
 
-    private void set(byte[] operation, byte[] register, short value) throws IOException {
-        communicationController.sendRobustRequest(operation, register, shortToBytes(value));
-    }
-
-    private byte[] shortToBytes(short s) {
-        return new byte[] { (byte) ((s & 0xFF00) >> 8), (byte) (s & 0x00FF) };
-    }
-
     private short getShort(byte[] operation, byte[] register) throws IOException {
         byte[] result = communicationController.sendRobustRequest(operation, register);
         return (short) ((result[0] << 8) + (result[1] & 0xff));
@@ -141,14 +124,6 @@ public class DanfossAirUnit {
         return f * 100 / 255;
     }
 
-    private void setSetting(byte[] register, short value) throws IOException {
-        byte[] valueArray = new byte[2];
-        valueArray[0] = (byte) (value >> 8);
-        valueArray[1] = (byte) value;
-
-        communicationController.sendRobustRequest(REGISTER_1_WRITE, register, valueArray);
-    }
-
     public String getUnitName() throws IOException {
         return getString(REGISTER_1_READ, UNIT_NAME);
     }
@@ -161,8 +136,12 @@ public class DanfossAirUnit {
         return new StringType(Mode.values()[getByte(REGISTER_1_READ, MODE)].name());
     }
 
-    public PercentType getManualFanStep() throws IOException {
-        return new PercentType(BigDecimal.valueOf(getByte(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP) * 10));
+    public PercentType getManualFanStep() throws IOException, UnexpectedResponseValueException {
+        byte value = getByte(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP);
+        if (value < 0 || value > 10) {
+            throw new UnexpectedResponseValueException(String.format("Invalid fan step: %d", value));
+        }
+        return new PercentType(BigDecimal.valueOf(value * 10));
     }
 
     public DecimalType getSupplyFanSpeed() throws IOException {
index c49ed3ce373450a38a4ab2cbcb325330cfafffb0..5b95646a888fa79b87fe3e0c8f7d562a201b7b59 100644 (file)
@@ -30,12 +30,6 @@ public class DanfossAirUnitBindingConstants {
 
     public static String BINDING_ID = "danfossairunit";
 
-    // List of all Thing Type UIDs
-    public static ThingTypeUID THING_TYPE_SAMPLE = new ThingTypeUID(BINDING_ID, "sample");
-
-    // List of all Channel ids
-    public static String CHANNEL_1 = "channel1";
-
     // The only thing type UIDs
     public static ThingTypeUID THING_TYPE_AIRUNIT = new ThingTypeUID(BINDING_ID, "airunit");
 
index f607e7f83b01477119c1ba03e9e1f374353219fe..c897163705604a0f0490bea2197585d773f7d9d4 100644 (file)
@@ -30,10 +30,13 @@ import org.slf4j.LoggerFactory;
  * The {@link DanfossAirUnitCommunicationController} class does the actual network communication with the air unit.
  *
  * @author Robert Bach - initial contribution
+ * @author Jacob Laursen - Refactoring, bugfixes and enhancements
  */
 
 @NonNullByDefault
-public class DanfossAirUnitCommunicationController {
+public class DanfossAirUnitCommunicationController implements CommunicationController {
+
+    private static final int SOCKET_TIMEOUT_MILLISECONDS = 5_000;
 
     private final Logger logger = LoggerFactory.getLogger(DanfossAirUnitCommunicationController.class);
 
@@ -41,8 +44,8 @@ public class DanfossAirUnitCommunicationController {
     private final int port;
     private boolean connected = false;
     private @Nullable Socket socket;
-    private @Nullable OutputStream oStream;
-    private @Nullable InputStream iStream;
+    private @Nullable OutputStream outputStream;
+    private @Nullable InputStream inputStream;
 
     public DanfossAirUnitCommunicationController(InetAddress inetAddr, int port) {
         this.inetAddr = inetAddr;
@@ -53,9 +56,11 @@ public class DanfossAirUnitCommunicationController {
         if (connected) {
             return;
         }
-        socket = new Socket(inetAddr, port);
-        oStream = socket.getOutputStream();
-        iStream = socket.getInputStream();
+        Socket localSocket = new Socket(inetAddr, port);
+        localSocket.setSoTimeout(SOCKET_TIMEOUT_MILLISECONDS);
+        this.outputStream = localSocket.getOutputStream();
+        this.inputStream = localSocket.getInputStream();
+        this.socket = localSocket;
         connected = true;
     }
 
@@ -64,15 +69,16 @@ public class DanfossAirUnitCommunicationController {
             return;
         }
         try {
-            if (socket != null) {
-                socket.close();
+            Socket localSocket = this.socket;
+            if (localSocket != null) {
+                localSocket.close();
             }
         } catch (IOException ioe) {
             logger.debug("Connection to air unit could not be closed gracefully. {}", ioe.getMessage());
         } finally {
-            socket = null;
-            iStream = null;
-            oStream = null;
+            this.socket = null;
+            this.inputStream = null;
+            this.outputStream = null;
         }
         connected = false;
     }
@@ -98,21 +104,27 @@ public class DanfossAirUnitCommunicationController {
     }
 
     private synchronized byte[] sendRequestInternal(byte[] request) throws IOException {
+        OutputStream localOutputStream = this.outputStream;
 
-        if (oStream == null) {
+        if (localOutputStream == null) {
             throw new IOException(
                     String.format("Output stream is null while sending request: %s", Arrays.toString(request)));
         }
-        oStream.write(request);
-        oStream.flush();
+        localOutputStream.write(request);
+        localOutputStream.flush();
 
         byte[] result = new byte[63];
-        if (iStream == null) {
+        InputStream localInputStream = this.inputStream;
+        if (localInputStream == null) {
             throw new IOException(
                     String.format("Input stream is null while sending request: %s", Arrays.toString(request)));
         }
-        // noinspection ResultOfMethodCallIgnored
-        iStream.read(result, 0, 63);
+
+        int bytesRead = localInputStream.read(result, 0, 63);
+        if (bytesRead < 63) {
+            throw new IOException(String.format(
+                    "Error reading from stream, read returned %d as number of bytes read into the buffer", bytesRead));
+        }
 
         return result;
     }
diff --git a/bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnitHandler.java b/bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnitHandler.java
deleted file mode 100644 (file)
index 52bdadd..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-/**
- * Copyright (c) 2010-2021 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 static org.openhab.binding.danfossairunit.internal.DanfossAirUnitBindingConstants.*;
-
-import java.io.IOException;
-import java.net.InetAddress;
-import java.net.UnknownHostException;
-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.ChannelUID;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.binding.BaseThingHandler;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.RefreshType;
-import org.openhab.core.types.State;
-import org.openhab.core.types.UnDefType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * The {@link DanfossAirUnitHandler} is responsible for handling commands, which are
- * sent to one of the channels.
- *
- * @author Ralf Duckstein - Initial contribution
- * @author Robert Bach - heavy refactorings
- */
-@NonNullByDefault
-public class DanfossAirUnitHandler extends BaseThingHandler {
-
-    private final Logger logger = LoggerFactory.getLogger(DanfossAirUnitHandler.class);
-    private @NonNullByDefault({}) DanfossAirUnitConfiguration config;
-    private @Nullable ValueCache valueCache;
-    private @Nullable ScheduledFuture<?> pollingJob;
-    private @Nullable DanfossAirUnit hrv;
-
-    public DanfossAirUnitHandler(Thing thing) {
-        super(thing);
-    }
-
-    @Override
-    public void handleCommand(ChannelUID channelUID, Command command) {
-        if (command instanceof RefreshType) {
-            updateAllChannels();
-        } else {
-            try {
-                DanfossAirUnit danfossAirUnit = hrv;
-                if (danfossAirUnit != null) {
-                    Channel channel = Channel.getByName(channelUID.getIdWithoutGroup());
-                    DanfossAirUnitWriteAccessor writeAccessor = channel.getWriteAccessor();
-                    if (writeAccessor != null) {
-                        updateState(channelUID, writeAccessor.access(danfossAirUnit, command));
-                    }
-                } else {
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE,
-                            "Air unit connection not initialized.");
-                    return;
-                }
-            } catch (IllegalArgumentException e) {
-                logger.debug("Ignoring unknown channel id: {}", channelUID.getIdWithoutGroup(), e);
-            } catch (IOException ioe) {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, ioe.getMessage());
-            }
-        }
-    }
-
-    @Override
-    public void initialize() {
-        updateStatus(ThingStatus.UNKNOWN);
-        config = getConfigAs(DanfossAirUnitConfiguration.class);
-        valueCache = new ValueCache(config.updateUnchangedValuesEveryMillis);
-        try {
-            hrv = new DanfossAirUnit(InetAddress.getByName(config.host), 30046);
-            DanfossAirUnit danfossAirUnit = hrv;
-            scheduler.execute(() -> {
-                try {
-                    thing.setProperty(PROPERTY_UNIT_NAME, danfossAirUnit.getUnitName());
-                    thing.setProperty(PROPERTY_SERIAL, danfossAirUnit.getUnitSerialNumber());
-                    pollingJob = scheduler.scheduleWithFixedDelay(this::updateAllChannels, 5, config.refreshInterval,
-                            TimeUnit.SECONDS);
-                    updateStatus(ThingStatus.ONLINE);
-                } catch (IOException e) {
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
-                }
-            });
-        } catch (UnknownHostException e) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
-                    "Unknown host: " + config.host);
-            return;
-        }
-    }
-
-    private void updateAllChannels() {
-        DanfossAirUnit danfossAirUnit = hrv;
-        if (danfossAirUnit != null) {
-            logger.debug("Updating DanfossHRV data '{}'", getThing().getUID());
-
-            for (Channel channel : Channel.values()) {
-                if (Thread.interrupted()) {
-                    logger.debug("Polling thread interrupted...");
-                    return;
-                }
-                try {
-                    updateState(channel.getGroup().getGroupName(), channel.getChannelName(),
-                            channel.getReadAccessor().access(danfossAirUnit));
-                } catch (UnexpectedResponseValueException e) {
-                    updateState(channel.getGroup().getGroupName(), channel.getChannelName(), UnDefType.UNDEF);
-                    logger.debug(
-                            "Cannot update channel {}: an unexpected or invalid response has been received from the air unit: {}",
-                            channel.getChannelName(), e.getMessage());
-                } catch (IOException e) {
-                    updateState(channel.getGroup().getGroupName(), channel.getChannelName(), UnDefType.UNDEF);
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
-                    logger.debug("Cannot update channel {}: an error occurred retrieving the value: {}",
-                            channel.getChannelName(), e.getMessage());
-                }
-            }
-
-            if (getThing().getStatus() == ThingStatus.OFFLINE) {
-                updateStatus(ThingStatus.ONLINE);
-            }
-        }
-    }
-
-    @Override
-    public void dispose() {
-        logger.debug("Disposing Danfoss HRV handler '{}'", getThing().getUID());
-
-        if (pollingJob != null) {
-            pollingJob.cancel(true);
-            pollingJob = null;
-        }
-
-        if (hrv != null) {
-            hrv.cleanUp();
-            hrv = null;
-        }
-    }
-
-    private void updateState(String groupId, String channelId, State state) {
-        if (valueCache.updateValue(channelId, state)) {
-            updateState(new ChannelUID(thing.getUID(), groupId, channelId), state);
-        }
-    }
-}
index 569741ff80232461bf828ce10eaf014c2d8deed7..e4bed6eb1873cea5a490c61f0a2fd3a031b46fca 100644 (file)
@@ -57,7 +57,6 @@ public class ValueCache {
         return writeToCache;
     }
 
-    @NonNullByDefault
     private static class StateWithTimestamp {
         State state;
         long timestamp;
index 2570d63444ff89772a3dbb07bc8379ff26e97665..df98da487953ec58ff1af25ac72a70d1c960f509 100644 (file)
@@ -51,11 +51,12 @@ public class DanfossAirUnitDiscoveryService extends AbstractDiscoveryService {
     private static final int BROADCAST_PORT = 30045;
     private static final byte[] DISCOVER_SEND = { 0x0c, 0x00, 0x30, 0x00, 0x11, 0x00, 0x12, 0x00, 0x13 };
     private static final byte[] DISCOVER_RECEIVE = { 0x0d, 0x00, 0x07, 0x00, 0x02, 0x02, 0x00 };
+    private static final int TIMEOUT_IN_SECONDS = 15;
 
     private final Logger logger = LoggerFactory.getLogger(DanfossAirUnitDiscoveryService.class);
 
     public DanfossAirUnitDiscoveryService() {
-        super(SUPPORTED_THING_TYPES_UIDS, 15, true);
+        super(SUPPORTED_THING_TYPES_UIDS, TIMEOUT_IN_SECONDS, true);
     }
 
     @Override
diff --git a/bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/handler/DanfossAirUnitHandler.java b/bundles/org.openhab.binding.danfossairunit/src/main/java/org/openhab/binding/danfossairunit/internal/handler/DanfossAirUnitHandler.java
new file mode 100644 (file)
index 0000000..0bf43f5
--- /dev/null
@@ -0,0 +1,189 @@
+/**
+ * Copyright (c) 2010-2021 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 static org.openhab.binding.danfossairunit.internal.DanfossAirUnitBindingConstants.*;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+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.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link DanfossAirUnitHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Ralf Duckstein - Initial contribution
+ * @author Robert Bach - heavy refactorings
+ * @author Jacob Laursen - Refactoring, bugfixes and enhancements
+ */
+@NonNullByDefault
+public class DanfossAirUnitHandler extends BaseThingHandler {
+
+    private static final int TCP_PORT = 30046;
+    private static final int POLLING_INTERVAL_SECONDS = 5;
+    private final Logger logger = LoggerFactory.getLogger(DanfossAirUnitHandler.class);
+    private @NonNullByDefault({}) DanfossAirUnitConfiguration config;
+    private @Nullable ValueCache valueCache;
+    private @Nullable ScheduledFuture<?> pollingJob;
+    private @Nullable DanfossAirUnitCommunicationController communicationController;
+    private @Nullable DanfossAirUnit airUnit;
+
+    public DanfossAirUnitHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            updateAllChannels();
+        } else {
+            try {
+                DanfossAirUnit localAirUnit = this.airUnit;
+                if (localAirUnit != null) {
+                    Channel channel = Channel.getByName(channelUID.getIdWithoutGroup());
+                    DanfossAirUnitWriteAccessor writeAccessor = channel.getWriteAccessor();
+                    if (writeAccessor != null) {
+                        updateState(channelUID, writeAccessor.access(localAirUnit, command));
+                    }
+                } else {
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE,
+                            "Air unit connection not initialized.");
+                    return;
+                }
+            } catch (IllegalArgumentException e) {
+                logger.debug("Ignoring unknown channel id: {}", channelUID.getIdWithoutGroup(), e);
+            } catch (IOException ioe) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, ioe.getMessage());
+            }
+        }
+    }
+
+    @Override
+    public void initialize() {
+        updateStatus(ThingStatus.UNKNOWN);
+        config = getConfigAs(DanfossAirUnitConfiguration.class);
+        valueCache = new ValueCache(config.updateUnchangedValuesEveryMillis);
+        try {
+            var localCommunicationController = new DanfossAirUnitCommunicationController(
+                    InetAddress.getByName(config.host), TCP_PORT);
+            this.communicationController = localCommunicationController;
+            var localAirUnit = new DanfossAirUnit(localCommunicationController);
+            this.airUnit = localAirUnit;
+            scheduler.execute(() -> {
+                try {
+                    thing.setProperty(PROPERTY_UNIT_NAME, localAirUnit.getUnitName());
+                    thing.setProperty(PROPERTY_SERIAL, localAirUnit.getUnitSerialNumber());
+                    startPolling();
+                    updateStatus(ThingStatus.ONLINE);
+                } catch (IOException e) {
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
+                }
+            });
+        } catch (UnknownHostException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+                    "Unknown host: " + config.host);
+            return;
+        }
+    }
+
+    private void updateAllChannels() {
+        DanfossAirUnit localAirUnit = this.airUnit;
+        if (localAirUnit == null) {
+            return;
+        }
+
+        logger.debug("Updating DanfossHRV data '{}'", getThing().getUID());
+
+        for (Channel channel : Channel.values()) {
+            if (Thread.interrupted()) {
+                logger.debug("Polling thread interrupted...");
+                return;
+            }
+            try {
+                updateState(channel.getGroup().getGroupName(), channel.getChannelName(),
+                        channel.getReadAccessor().access(localAirUnit));
+                if (getThing().getStatus() == ThingStatus.OFFLINE) {
+                    updateStatus(ThingStatus.ONLINE);
+                }
+            } catch (UnexpectedResponseValueException e) {
+                updateState(channel.getGroup().getGroupName(), channel.getChannelName(), UnDefType.UNDEF);
+                logger.debug(
+                        "Cannot update channel {}: an unexpected or invalid response has been received from the air unit: {}",
+                        channel.getChannelName(), e.getMessage());
+                if (getThing().getStatus() == ThingStatus.OFFLINE) {
+                    updateStatus(ThingStatus.ONLINE);
+                }
+            } catch (IOException e) {
+                updateState(channel.getGroup().getGroupName(), channel.getChannelName(), UnDefType.UNDEF);
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
+                logger.debug("Cannot update channel {}: an error occurred retrieving the value: {}",
+                        channel.getChannelName(), e.getMessage());
+            }
+        }
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("Disposing Danfoss HRV handler '{}'", getThing().getUID());
+
+        stopPolling();
+
+        this.airUnit = null;
+
+        DanfossAirUnitCommunicationController localCommunicationController = this.communicationController;
+        if (localCommunicationController != null) {
+            localCommunicationController.disconnect();
+        }
+        this.communicationController = null;
+    }
+
+    private synchronized void startPolling() {
+        this.pollingJob = scheduler.scheduleWithFixedDelay(this::updateAllChannels, POLLING_INTERVAL_SECONDS,
+                config.refreshInterval, TimeUnit.SECONDS);
+    }
+
+    private synchronized void stopPolling() {
+        ScheduledFuture<?> localPollingJob = this.pollingJob;
+        if (localPollingJob != null) {
+            localPollingJob.cancel(true);
+        }
+        this.pollingJob = null;
+    }
+
+    private void updateState(String groupId, String channelId, State state) {
+        ValueCache cache = valueCache;
+        if (cache == null) {
+            return;
+        }
+
+        if (cache.updateValue(channelId, state)) {
+            updateState(new ChannelUID(thing.getUID(), groupId, channelId), state);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.danfossairunit/src/test/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnitTest.java b/bundles/org.openhab.binding.danfossairunit/src/test/java/org/openhab/binding/danfossairunit/internal/DanfossAirUnitTest.java
new file mode 100644 (file)
index 0000000..f515f9d
--- /dev/null
@@ -0,0 +1,156 @@
+/**
+ * Copyright (c) 2010-2021 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 static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.openhab.binding.danfossairunit.internal.Commands.*;
+
+import java.io.IOException;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.openhab.core.library.types.DateTimeType;
+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.test.java.JavaTest;
+
+/**
+ * This class provides test cases for {@link DanfossAirUnit}
+ * 
+ * @author Jacob Laursen - Refactoring, bugfixes and enhancements
+ */
+public class DanfossAirUnitTest extends JavaTest {
+
+    private CommunicationController communicationController;
+
+    @BeforeEach
+    private void setUp() {
+        this.communicationController = mock(CommunicationController.class);
+    }
+
+    @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);
+        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);
+        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);
+        assertEquals(new QuantityType<>("40.4 %"), airUnit.getHumidity());
+    }
+
+    @Test
+    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);
+        assertEquals(new QuantityType<>("25.4 °C"), airUnit.getSupplyTemperature());
+    }
+
+    @Test
+    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);
+        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);
+        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);
+        assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getSupplyTemperature());
+    }
+
+    @Test
+    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())),
+                airUnit.getCurrentTime());
+    }
+
+    @Test
+    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);
+        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);
+        assertEquals(OnOffType.OFF, airUnit.getBoost());
+    }
+
+    @Test
+    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);
+        assertEquals(OnOffType.ON, airUnit.getBoost());
+    }
+
+    @Test
+    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);
+        assertEquals(new PercentType(50), airUnit.getManualFanStep());
+    }
+
+    @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);
+        assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getManualFanStep());
+    }
+}