From c41c38405e8c8dbb0a2bb95fd971ec057cc81e19 Mon Sep 17 00:00:00 2001 From: Stefan Giehl Date: Sat, 1 Oct 2022 17:15:16 +0200 Subject: [PATCH] [LuxtronikHeatpump] Adds discovery service (#11907) * [LuxtronikHeatpump] Adds discovery service Signed-off-by: Stefan Giehl --- .../README.md | 4 + .../internal/ChannelUpdaterJob.java | 38 +-- .../LuxtronikHeatpumpBindingConstants.java | 5 + .../discovery/LuxtronikHeatpumpDiscovery.java | 233 ++++++++++++++++++ 4 files changed, 266 insertions(+), 14 deletions(-) create mode 100644 bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/discovery/LuxtronikHeatpumpDiscovery.java diff --git a/bundles/org.openhab.binding.luxtronikheatpump/README.md b/bundles/org.openhab.binding.luxtronikheatpump/README.md index 0dbcafb36..2d64c4117 100644 --- a/bundles/org.openhab.binding.luxtronikheatpump/README.md +++ b/bundles/org.openhab.binding.luxtronikheatpump/README.md @@ -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: diff --git a/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/ChannelUpdaterJob.java b/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/ChannelUpdaterJob.java index 44ee5d683..9cfb90a35 100644 --- a/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/ChannelUpdaterJob.java +++ b/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/ChannelUpdaterJob.java @@ -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 getProperties(Integer[] heatpumpValues) { + Map properties = new HashMap(); + 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); - setProperty("softwareVersion", getSoftwareVersion(heatpumpValues)); - setProperty("ipAddress", transformIpAddress(heatpumpValues[91])); - setProperty("subnetMask", transformIpAddress(heatpumpValues[92])); - setProperty("broadcastAddress", transformIpAddress(heatpumpValues[93])); - setProperty("gateway", transformIpAddress(heatpumpValues[94])); + 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; + } + + private void updateProperties(Integer[] heatpumpValues) { + Map properties = getProperties(heatpumpValues); + + for (Map.Entry 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 = ""; diff --git a/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/LuxtronikHeatpumpBindingConstants.java b/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/LuxtronikHeatpumpBindingConstants.java index df4d51b25..a1e544576 100644 --- a/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/LuxtronikHeatpumpBindingConstants.java +++ b/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/LuxtronikHeatpumpBindingConstants.java @@ -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 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 index 000000000..0ca181788 --- /dev/null +++ b/bundles/org.openhab.binding.luxtronikheatpump/src/main/java/org/openhab/binding/luxtronikheatpump/internal/discovery/LuxtronikHeatpumpDiscovery.java @@ -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: + *
    + *
  • Request this hosts first IPV4 address.
  • + *
  • Send a socket request on port 8888 / 8889 to all IPs on the subnet.
  • + *
  • The response is then investigated to see if is an answer from a heat pump
  • + *
+ * 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 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 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; + } + } +}