]> git.basschouten.com Git - openhab-addons.git/commitdiff
[LuxtronikHeatpump] Adds discovery service (#11907)
authorStefan Giehl <stefangiehl@gmail.com>
Sat, 1 Oct 2022 15:15:16 +0000 (17:15 +0200)
committerGitHub <noreply@github.com>
Sat, 1 Oct 2022 15:15:16 +0000 (17:15 +0200)
* [LuxtronikHeatpump] Adds discovery service

Signed-off-by: Stefan Giehl <stefangiehl@gmail.com>
bundles/org.openhab.binding.luxtronikheatpump/README.md
bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/ChannelUpdaterJob.java
bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/LuxtronikHeatpumpBindingConstants.java
bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/discovery/LuxtronikHeatpumpDiscovery.java [new file with mode: 0644]

index 0dbcafb362cb5ff52aa31926c3bdd17b782ee67e..2d64c4117ea7a5164fc66243bb52698cafebfc45 100644 (file)
@@ -23,6 +23,10 @@ Note: The whole functionality is based on data that was reverse engineered, so u
 
 This binding only supports one thing type "Luxtronik Heatpump" (heatpump).
 
+## Discovery
+
+This binding will try to detect heat pumps that are reachable in the same IPv4 subnet.
+
 ## Thing Configuration
 
 Each heatpump requires the following configuration parameters:
index 44ee5d683634522ad6ff72026edf93c17be2c393..9cfb90a35e3fa6448652fa27c8986cfd786fca0b 100644 (file)
@@ -16,6 +16,8 @@ import java.io.IOException;
 import java.time.DateTimeException;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.HashMap;
+import java.util.Map;
 
 import javax.measure.Unit;
 
@@ -179,7 +181,7 @@ public class ChannelUpdaterJob implements SchedulerRunnable, Runnable {
         return rawValue;
     }
 
-    private String getSoftwareVersion(Integer[] heatpumpValues) {
+    private static String getSoftwareVersion(Integer[] heatpumpValues) {
         StringBuffer softwareVersion = new StringBuffer("");
 
         for (int i = 81; i <= 90; i++) {
@@ -191,7 +193,7 @@ public class ChannelUpdaterJob implements SchedulerRunnable, Runnable {
         return softwareVersion.toString();
     }
 
-    private String transformIpAddress(int ip) {
+    private static String transformIpAddress(int ip) {
         return String.format("%d.%d.%d.%d", (ip >> 24) & 0xFF, (ip >> 16) & 0xFF, (ip >> 8) & 0xFF, ip & 0xFF);
     }
 
@@ -225,20 +227,32 @@ public class ChannelUpdaterJob implements SchedulerRunnable, Runnable {
         handleEventType(new StringType(longState), HeatpumpChannel.CHANNEL_HEATPUMP_STATUS);
     }
 
-    private void updateProperties(Integer[] heatpumpValues) {
+    public static Map<String, Object> getProperties(Integer[] heatpumpValues) {
+        Map<String, Object> properties = new HashMap<String, Object>();
+
         String heatpumpType = HeatpumpType.fromCode(heatpumpValues[78]).getName();
 
-        setProperty("heatpumpType", heatpumpType);
+        properties.put("heatpumpType", heatpumpType);
 
         // Not sure when Typ 2 should be used
         // String heatpumpType2 = HeatpumpType.fromCode(heatpumpValues[230]).getName();
-        // setProperty("heatpumpType2", heatpumpType2);
+        // properties.put("heatpumpType2", heatpumpType2);
+
+        properties.put("softwareVersion", getSoftwareVersion(heatpumpValues));
+        properties.put("ipAddress", transformIpAddress(heatpumpValues[91]));
+        properties.put("subnetMask", transformIpAddress(heatpumpValues[92]));
+        properties.put("broadcastAddress", transformIpAddress(heatpumpValues[93]));
+        properties.put("gateway", transformIpAddress(heatpumpValues[94]));
+
+        return properties;
+    }
 
-        setProperty("softwareVersion", getSoftwareVersion(heatpumpValues));
-        setProperty("ipAddress", transformIpAddress(heatpumpValues[91]));
-        setProperty("subnetMask", transformIpAddress(heatpumpValues[92]));
-        setProperty("broadcastAddress", transformIpAddress(heatpumpValues[93]));
-        setProperty("gateway", transformIpAddress(heatpumpValues[94]));
+    private void updateProperties(Integer[] heatpumpValues) {
+        Map<String, Object> properties = getProperties(heatpumpValues);
+
+        for (Map.Entry<String, Object> property : properties.entrySet()) {
+            handler.updateProperty(property.getKey(), property.getValue().toString());
+        }
     }
 
     private String getStateTranslation(String name, @Nullable Integer option) {
@@ -251,10 +265,6 @@ public class ChannelUpdaterJob implements SchedulerRunnable, Runnable {
         return translation == null ? "" : translation;
     }
 
-    private void setProperty(String name, String value) {
-        handler.updateProperty(name, value);
-    }
-
     private String formatHours(@Nullable Integer value) {
         String returnValue = "";
 
index df4d51b253cbf1979afa7f98ffe968e7d5ff46b3..a1e544576c415620898ac19c9c247e26e74361e7 100644 (file)
@@ -12,6 +12,9 @@
  */
 package org.openhab.binding.luxtronikheatpump.internal;
 
+import java.util.Collections;
+import java.util.Set;
+
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.openhab.core.thing.ThingTypeUID;
 
@@ -27,4 +30,6 @@ public class LuxtronikHeatpumpBindingConstants {
     public static final String BINDING_ID = "luxtronikheatpump";
 
     public static final ThingTypeUID THING_TYPE_HEATPUMP = new ThingTypeUID(BINDING_ID, "heatpump");
+
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_HEATPUMP);
 }
diff --git a/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/discovery/LuxtronikHeatpumpDiscovery.java b/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/discovery/LuxtronikHeatpumpDiscovery.java
new file mode 100644 (file)
index 0000000..0ca1817
--- /dev/null
@@ -0,0 +1,233 @@
+/**
+ * Copyright (c) 2010-2022 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.luxtronikheatpump.internal.discovery;
+
+import java.io.IOException;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.luxtronikheatpump.internal.ChannelUpdaterJob;
+import org.openhab.binding.luxtronikheatpump.internal.HeatpumpConnector;
+import org.openhab.binding.luxtronikheatpump.internal.LuxtronikHeatpumpBindingConstants;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.net.CidrAddress;
+import org.openhab.core.net.NetUtil;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Discovery class for Luxtronik heat pumps.
+ * As the heat pump seems undiscoverable using mdns or upnp we currently iterate over all
+ * IPs and send a socket request on port 8888 / 8889 and detect new heat pumps based on the results.
+ *
+ * @author Stefan Giehl - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = { DiscoveryService.class,
+        LuxtronikHeatpumpDiscovery.class }, configurationPid = "discovery.luxtronik")
+public class LuxtronikHeatpumpDiscovery extends AbstractDiscoveryService {
+
+    private final Logger logger = LoggerFactory.getLogger(LuxtronikHeatpumpDiscovery.class);
+
+    /**
+     * HTTP read timeout (in milliseconds) - allows us to shutdown the listening every TIMEOUT
+     */
+    private static final int TIMEOUT_MS = 500;
+
+    /**
+     * Timeout in seconds of the complete scan
+     */
+    private static final int FULL_SCAN_TIMEOUT_SECONDS = 30;
+
+    /**
+     * Total number of concurrent threads during scanning.
+     */
+    private static final int SCAN_THREADS = 10;
+
+    /**
+     * Whether we are currently scanning or not
+     */
+    private boolean scanning;
+
+    private int octet;
+    private int ipMask;
+    private int addressCount;
+    private @Nullable CidrAddress baseIp;
+
+    /**
+     * The {@link ExecutorService} to run the listening threads on.
+     */
+    private @Nullable ExecutorService executorService;
+
+    /**
+     * Constructs the discovery class using the thing IDs that we can discover.
+     */
+    public LuxtronikHeatpumpDiscovery() {
+        super(LuxtronikHeatpumpBindingConstants.SUPPORTED_THING_TYPES_UIDS, FULL_SCAN_TIMEOUT_SECONDS, false);
+    }
+
+    private void setupBaseIp(CidrAddress adr) {
+        byte[] octets = adr.getAddress().getAddress();
+        addressCount = (1 << (32 - adr.getPrefix())) - 2;
+        ipMask = 0xFFFFFFFF << (32 - adr.getPrefix());
+        octets[0] &= ipMask >> 24;
+        octets[1] &= ipMask >> 16;
+        octets[2] &= ipMask >> 8;
+        octets[3] &= ipMask;
+        try {
+            InetAddress iAdr = InetAddress.getByAddress(octets);
+            baseIp = new CidrAddress(iAdr, (short) adr.getPrefix());
+        } catch (UnknownHostException e) {
+            logger.debug("Could not build net ip address.", e);
+        }
+        octet = 0;
+    }
+
+    private synchronized String getNextIPAddress(CidrAddress adr) {
+        octet++;
+        octet &= ~ipMask;
+        byte[] octets = adr.getAddress().getAddress();
+        octets[2] += (octet >> 8);
+        octets[3] += octet;
+        String address = "";
+        try {
+            InetAddress iAdr = null;
+            iAdr = InetAddress.getByAddress(octets);
+            address = iAdr.getHostAddress();
+        } catch (UnknownHostException e) {
+            logger.debug("Could not find next ip address.", e);
+        }
+        return address;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * Starts the scan. This discovery will:
+     * <ul>
+     * <li>Request this hosts first IPV4 address.</li>
+     * <li>Send a socket request on port 8888 / 8889 to all IPs on the subnet.</li>
+     * <li>The response is then investigated to see if is an answer from a heat pump</li>
+     * </ul>
+     * The process will continue until all addresses are checked, timeout or {@link #stopScan()} is called.
+     */
+    @Override
+    protected void startScan() {
+        if (executorService != null) {
+            stopScan();
+        }
+
+        CidrAddress localAdr = getLocalIP4Address();
+        if (localAdr == null) {
+            stopScan();
+            return;
+        }
+        setupBaseIp(localAdr);
+        CidrAddress baseAdr = baseIp;
+        scanning = true;
+        ExecutorService localExecutorService = Executors.newFixedThreadPool(SCAN_THREADS);
+        executorService = localExecutorService;
+        for (int i = 0; i < addressCount; i++) {
+
+            localExecutorService.execute(() -> {
+                if (scanning && baseAdr != null) {
+                    String ipAdd = getNextIPAddress(baseAdr);
+
+                    if (!discoverFromIp(ipAdd, 8889)) {
+                        discoverFromIp(ipAdd, 8888);
+                    }
+                }
+            });
+        }
+    }
+
+    private boolean discoverFromIp(String ipAdd, int port) {
+        HeatpumpConnector connection = new HeatpumpConnector(ipAdd, port);
+
+        try {
+            connection.read();
+            Integer[] heatpumpValues = connection.getValues();
+            Map<String, Object> properties = ChannelUpdaterJob.getProperties(heatpumpValues);
+            properties.put("port", port);
+
+            String type = properties.get("heatpumpType").toString();
+            ThingTypeUID typeId = LuxtronikHeatpumpBindingConstants.THING_TYPE_HEATPUMP;
+            ThingUID uid = new ThingUID(typeId, type);
+
+            DiscoveryResult result = DiscoveryResultBuilder.create(uid).withProperties(properties).withLabel(type)
+                    .build();
+            thingDiscovered(result);
+
+            return true;
+        } catch (IOException e) {
+            // no heatpump found on given ip / port
+        }
+
+        return false;
+    }
+
+    /**
+     * Tries to find valid IP4 address.
+     *
+     * @return An IP4 address or null if none is found.
+     */
+    private @Nullable CidrAddress getLocalIP4Address() {
+        List<CidrAddress> adrList = NetUtil.getAllInterfaceAddresses().stream()
+                .filter(a -> a.getAddress() instanceof Inet4Address).collect(Collectors.toList());
+
+        for (CidrAddress adr : adrList) {
+            // Don't return a "fake" DHCP lease.
+            if (!adr.toString().startsWith("169.254.")) {
+                return adr;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * Stops the discovery scan. We set {@link #scanning} to false (allowing the listening threads to end naturally
+     * within {@link #TIMEOUT_MS) * {@link #SCAN_THREADS} time then shutdown the {@link #executorService}
+     */
+    @Override
+    protected synchronized void stopScan() {
+        super.stopScan();
+        ExecutorService localExecutorService = executorService;
+        if (localExecutorService != null) {
+            scanning = false;
+            try {
+                localExecutorService.awaitTermination(TIMEOUT_MS * SCAN_THREADS, TimeUnit.MILLISECONDS);
+            } catch (InterruptedException e) {
+                logger.debug("Stop scan interrupted.", e);
+            }
+            localExecutorService.shutdown();
+            executorService = null;
+        }
+    }
+}