2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.network.internal;
15 import java.io.IOException;
16 import java.net.Inet4Address;
17 import java.net.InetAddress;
18 import java.net.SocketException;
19 import java.net.UnknownHostException;
20 import java.util.HashSet;
22 import java.util.concurrent.ExecutorService;
23 import java.util.concurrent.Executors;
24 import java.util.concurrent.ScheduledExecutorService;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.function.Consumer;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.network.internal.dhcp.DHCPListenService;
32 import org.openhab.binding.network.internal.dhcp.IPRequestReceivedCallback;
33 import org.openhab.binding.network.internal.toberemoved.cache.ExpiringCacheAsync;
34 import org.openhab.binding.network.internal.utils.NetworkUtils;
35 import org.openhab.binding.network.internal.utils.NetworkUtils.ArpPingUtilEnum;
36 import org.openhab.binding.network.internal.utils.NetworkUtils.IpPingMethodEnum;
37 import org.openhab.binding.network.internal.utils.PingResult;
38 import org.openhab.core.cache.ExpiringCache;
39 import org.slf4j.Logger;
40 import org.slf4j.LoggerFactory;
43 * The {@link PresenceDetection} handles the connection to the Device
45 * @author Marc Mettke - Initial contribution
46 * @author David Gräff, 2017 - Rewritten
47 * @author Jan N. Klug - refactored host name resolution
50 public class PresenceDetection implements IPRequestReceivedCallback {
52 public static final double NOT_REACHABLE = -1;
53 public static final int DESTINATION_TTL = 300 * 1000; // in ms, 300 s
55 NetworkUtils networkUtils = new NetworkUtils();
56 private final Logger logger = LoggerFactory.getLogger(PresenceDetection.class);
58 /// Configuration variables
59 private boolean useDHCPsniffing = false;
60 private String arpPingState = "Disabled";
61 private String ipPingState = "Disabled";
62 protected String arpPingUtilPath = "";
63 protected ArpPingUtilEnum arpPingMethod = ArpPingUtilEnum.UNKNOWN_TOOL;
64 protected @Nullable IpPingMethodEnum pingMethod = null;
65 private boolean iosDevice;
66 private Set<Integer> tcpPorts = new HashSet<>();
68 private long refreshIntervalInMS = 60000;
69 private int timeoutInMS = 5000;
70 private long lastSeenInMS;
72 private @NonNullByDefault({}) String hostname;
73 private @NonNullByDefault({}) ExpiringCache<@Nullable InetAddress> destination;
74 private @Nullable InetAddress cachedDestination = null;
76 public boolean preferResponseTimeAsLatency;
78 /// State variables (cannot be final because of test dependency injections)
79 ExpiringCacheAsync<PresenceDetectionValue> cache;
80 private final PresenceDetectionListener updateListener;
81 private @Nullable ScheduledFuture<?> refreshJob;
82 protected @Nullable ExecutorService executorService;
83 private String dhcpState = "off";
84 Integer currentCheck = 0;
87 public PresenceDetection(final PresenceDetectionListener updateListener, int cacheDeviceStateTimeInMS)
88 throws IllegalArgumentException {
89 this.updateListener = updateListener;
90 cache = new ExpiringCacheAsync<>(cacheDeviceStateTimeInMS, () -> {
91 performPresenceDetection(false);
95 public @Nullable String getHostname() {
99 public Set<Integer> getServicePorts() {
103 public long getRefreshInterval() {
104 return refreshIntervalInMS;
107 public int getTimeout() {
111 public void setHostname(String hostname) {
112 this.hostname = hostname;
113 this.destination = new ExpiringCache<>(DESTINATION_TTL, () -> {
115 InetAddress destinationAddress = InetAddress.getByName(hostname);
116 if (!destinationAddress.equals(cachedDestination)) {
117 logger.trace("host name resolved to other address, (re-)setup presence detection");
118 setUseArpPing(true, destinationAddress);
119 if (useDHCPsniffing) {
120 if (cachedDestination != null) {
121 disableDHCPListen(cachedDestination);
123 enableDHCPListen(destinationAddress);
125 cachedDestination = destinationAddress;
127 return destinationAddress;
128 } catch (UnknownHostException e) {
129 logger.trace("hostname resolution failed");
130 if (cachedDestination != null) {
131 disableDHCPListen(cachedDestination);
132 cachedDestination = null;
139 public void setServicePorts(Set<Integer> ports) {
140 this.tcpPorts = ports;
143 public void setUseDhcpSniffing(boolean enable) {
144 this.useDHCPsniffing = enable;
147 public void setRefreshInterval(long refreshInterval) {
148 this.refreshIntervalInMS = refreshInterval;
151 public void setTimeout(int timeout) {
152 this.timeoutInMS = timeout;
155 public void setPreferResponseTimeAsLatency(boolean preferResponseTimeAsLatency) {
156 this.preferResponseTimeAsLatency = preferResponseTimeAsLatency;
160 * Sets the ping method. This method will perform a feature test. If SYSTEM_PING
161 * does not work on this system, JAVA_PING will be used instead.
163 * @param useSystemPing Set to true to use a system ping method, false to use java ping and null to disable ICMP
166 public void setUseIcmpPing(@Nullable Boolean useSystemPing) {
167 if (useSystemPing == null) {
168 ipPingState = "Disabled";
170 } else if (useSystemPing) {
171 final IpPingMethodEnum pingMethod = networkUtils.determinePingMethod();
172 this.pingMethod = pingMethod;
173 ipPingState = pingMethod == IpPingMethodEnum.JAVA_PING ? "System ping feature test failed. Using Java ping"
176 pingMethod = IpPingMethodEnum.JAVA_PING;
177 ipPingState = "Java ping";
182 * Enables or disables ARP pings. Will be automatically disabled if the destination
183 * is not an IPv4 address. If the feature test for the native arping utility fails,
184 * it will be disabled as well.
186 * @param enable Enable or disable ARP ping
187 * @param arpPingUtilPath c
189 private void setUseArpPing(boolean enable, @Nullable InetAddress destinationAddress) {
190 if (!enable || arpPingUtilPath.isEmpty()) {
191 arpPingState = "Disabled";
192 arpPingMethod = ArpPingUtilEnum.UNKNOWN_TOOL;
194 } else if (destinationAddress == null || !(destinationAddress instanceof Inet4Address)) {
195 arpPingState = "Destination is not a valid IPv4 address";
196 arpPingMethod = ArpPingUtilEnum.UNKNOWN_TOOL;
200 switch (arpPingMethod) {
202 arpPingState = "Unknown arping tool";
205 case THOMAS_HABERT_ARPING: {
206 arpPingState = "Arping tool by Thomas Habets";
209 case THOMAS_HABERT_ARPING_WITHOUT_TIMEOUT: {
210 arpPingState = "Arping tool by Thomas Habets (old version)";
213 case ELI_FULKERSON_ARP_PING_FOR_WINDOWS: {
214 arpPingState = "Eli Fulkerson ARPing tool for Windows";
217 case IPUTILS_ARPING: {
218 arpPingState = "Ipuitls Arping";
225 * sets the path to arp ping
227 * @param enable Enable or disable ARP ping
228 * @param arpPingUtilPath enableDHCPListen(useDHCPsniffing);
230 public void setUseArpPing(boolean enable, String arpPingUtilPath, ArpPingUtilEnum arpPingUtilMethod) {
231 setUseArpPing(enable, destination.getValue());
232 this.arpPingUtilPath = arpPingUtilPath;
233 this.arpPingMethod = arpPingUtilMethod;
236 public String getArpPingState() {
240 public String getIPPingState() {
244 public String getDhcpState() {
249 * Return true if the device presence detection is performed for an iOS device
250 * like iPhone or iPads. An additional port knock is performed before a ping.
252 public boolean isIOSdevice() {
257 * Set to true if the device presence detection should be performed for an iOS device
258 * like iPhone or iPads. An additional port knock is performed before a ping.
260 public void setIOSDevice(boolean value) {
265 * Return the last seen value in milliseconds based on {@link System.currentTimeMillis()} or 0 if not seen yet.
267 public long getLastSeen() {
272 * Return asynchronously the value of the presence detection as a PresenceDetectionValue.
274 * @param callback A callback with the PresenceDetectionValue. The callback may
275 * not happen immediately if the cached value expired, but as soon as a new
276 * discovery took place.
278 public void getValue(Consumer<PresenceDetectionValue> callback) {
279 cache.getValue(callback);
282 public ExecutorService getThreadsFor(int threadCount) {
283 return Executors.newFixedThreadPool(threadCount);
287 * Perform a presence detection with ICMP-, ARP ping and
288 * TCP connection attempts simultaneously. A fixed thread pool will be created with as many
289 * thread as necessary to perform all tests at once.
291 * This is a NO-OP, if there is already an ongoing detection or if the cached value
292 * is not expired yet.
294 * Please be aware of the following restrictions:
295 * - ARP pings are only executed on IPv4 addresses.
296 * - Non system / Java pings are not recommended at all
297 * (not interruptible, useless TCP echo service fall back)
299 * @param waitForDetectionToFinish If you want to synchronously wait for the result, set this to true
300 * @return Return true if a presence detection is performed and false otherwise.
302 public boolean performPresenceDetection(boolean waitForDetectionToFinish) {
303 if (executorService != null) {
305 "There is already an ongoing presence discovery for {} and a new one was issued by the scheduler! TCP Port {}",
310 if (!cache.isExpired()) {
314 Set<String> interfaceNames = null;
317 detectionChecks = tcpPorts.size();
318 if (pingMethod != null) {
319 detectionChecks += 1;
321 if (arpPingMethod != ArpPingUtilEnum.UNKNOWN_TOOL) {
322 interfaceNames = networkUtils.getInterfaceNames();
323 detectionChecks += interfaceNames.size();
326 if (detectionChecks == 0) {
330 final ExecutorService executorService = getThreadsFor(detectionChecks);
331 this.executorService = executorService;
333 for (Integer tcpPort : tcpPorts) {
334 executorService.execute(() -> {
335 Thread.currentThread().setName("presenceDetectionTCP_" + hostname + " " + String.valueOf(tcpPort));
336 performServicePing(tcpPort);
341 // ARP ping for IPv4 addresses. Use single executor for Windows tool and
342 // each own executor for each network interface for other tools
343 if (arpPingMethod == ArpPingUtilEnum.ELI_FULKERSON_ARP_PING_FOR_WINDOWS) {
344 executorService.execute(() -> {
345 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " ");
346 // arp-ping.exe tool capable of handling multiple interfaces by itself
350 } else if (interfaceNames != null) {
351 for (final String interfaceName : interfaceNames) {
352 executorService.execute(() -> {
353 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " " + interfaceName);
354 performARPping(interfaceName);
361 if (pingMethod != null) {
362 executorService.execute(() -> {
363 if (pingMethod != IpPingMethodEnum.JAVA_PING) {
364 Thread.currentThread().setName("presenceDetectionICMP_" + hostname);
373 if (waitForDetectionToFinish) {
374 waitForPresenceDetection();
381 * Calls updateListener.finalDetectionResult() with a final result value.
382 * Safe to be called from different threads. After a call to this method,
383 * the presence detection process is finished and all threads are forcefully
386 private synchronized void submitFinalResult() {
387 // Do nothing if we are not in a detection process
388 ExecutorService service = executorService;
389 if (service == null) {
392 // Finish the detection process
393 service.shutdownNow();
394 executorService = null;
397 PresenceDetectionValue v;
399 // The cache will be expired by now if cache_time < timeoutInMS. But the device might be actually reachable.
400 // Therefore use lastSeenInMS here and not cache.isExpired() to determine if we got a ping response.
401 if (lastSeenInMS + timeoutInMS + 100 < System.currentTimeMillis()) {
402 // We haven't seen the device in the detection process
403 v = new PresenceDetectionValue(hostname, -1);
405 // Make the cache valid again and submit the value.
406 v = cache.getExpiredValue();
410 if (!v.isReachable()) {
411 // if target can't be reached, check if name resolution need to be updated
412 destination.invalidateValue();
414 updateListener.finalDetectionResult(v);
418 * This method is called after each individual check and increases a check counter.
419 * If the counter equals the total checks,the final result is submitted. This will
420 * happen way before the "timeoutInMS", if all checks were successful.
423 private synchronized void checkIfFinished() {
425 if (currentCheck < detectionChecks) {
432 * Waits for the presence detection threads to finish. Returns immediately
433 * if no presence detection is performed right now.
435 public void waitForPresenceDetection() {
436 ExecutorService service = executorService;
437 if (service == null) {
441 // We may get interrupted here by cancelRefreshJob().
442 service.awaitTermination(timeoutInMS + 100, TimeUnit.MILLISECONDS);
444 } catch (InterruptedException e) {
445 Thread.currentThread().interrupt(); // Reset interrupt flag
446 service.shutdownNow();
447 executorService = null;
452 * If the cached PresenceDetectionValue has not expired yet, the cached version
453 * is returned otherwise a new reachable PresenceDetectionValue is created with
456 * It is safe to call this method from multiple threads. The returned PresenceDetectionValue
457 * might be still be altered in other threads though.
459 * @param type The detection type
460 * @return The non expired or a new instance of PresenceDetectionValue.
462 synchronized PresenceDetectionValue updateReachableValue(PresenceDetectionType type, double latency) {
463 lastSeenInMS = System.currentTimeMillis();
464 PresenceDetectionValue v;
465 if (cache.isExpired()) {
466 v = new PresenceDetectionValue(hostname, 0);
468 v = cache.getExpiredValue();
470 v.updateLatency(latency);
476 protected void performServicePing(int tcpPort) {
477 logger.trace("Perform TCP presence detection for {} on port: {}", hostname, tcpPort);
479 InetAddress destinationAddress = destination.getValue();
480 if (destinationAddress != null) {
481 networkUtils.servicePing(destinationAddress.getHostAddress(), tcpPort, timeoutInMS).ifPresent(o -> {
483 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.TCP_CONNECTION,
484 getLatency(o, preferResponseTimeAsLatency));
485 v.addReachableTcpService(tcpPort);
486 updateListener.partialDetectionResult(v);
490 } catch (IOException e) {
491 // This should not happen and might be a user configuration issue, we log a warning message therefore.
492 logger.warn("Could not create a socket connection", e);
497 * Performs an "ARP ping" (ARP request) on the given interface.
498 * If it is an iOS device, the {@see NetworkUtils.wakeUpIOS()} method is
499 * called before performing the ARP ping.
501 * @param interfaceName The interface name. You can request a list of interface names
502 * from {@see NetworkUtils.getInterfaceNames()} for example.
504 protected void performARPping(String interfaceName) {
506 logger.trace("Perform ARP ping presence detection for {} on interface: {}", hostname, interfaceName);
507 InetAddress destinationAddress = destination.getValue();
508 if (destinationAddress == null) {
512 networkUtils.wakeUpIOS(destinationAddress);
516 networkUtils.nativeARPPing(arpPingMethod, arpPingUtilPath, interfaceName,
517 destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
519 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ARP_PING,
520 getLatency(o, preferResponseTimeAsLatency));
521 updateListener.partialDetectionResult(v);
524 } catch (IOException e) {
525 logger.trace("Failed to execute an arp ping for ip {}", hostname, e);
526 } catch (InterruptedException ignored) {
527 // This can be ignored, the thread will end anyway
532 * Performs a java ping. It is not recommended to use this, as it is not interruptible,
533 * and will not work on windows systems reliably and will fall back from ICMP pings to
534 * the TCP echo service on port 7 which barely no device or server supports nowadays.
535 * (http://docs.oracle.com/javase/7/docs/api/java/net/InetAddress.html#isReachable%28int%29)
537 protected void performJavaPing() {
538 logger.trace("Perform java ping presence detection for {}", hostname);
540 InetAddress destinationAddress = destination.getValue();
541 if (destinationAddress == null) {
545 networkUtils.javaPing(timeoutInMS, destinationAddress).ifPresent(o -> {
547 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
548 getLatency(o, preferResponseTimeAsLatency));
549 updateListener.partialDetectionResult(v);
554 protected void performSystemPing() {
556 logger.trace("Perform native ping presence detection for {}", hostname);
557 InetAddress destinationAddress = destination.getValue();
558 if (destinationAddress == null) {
562 networkUtils.nativePing(pingMethod, destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
564 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
565 getLatency(o, preferResponseTimeAsLatency));
566 updateListener.partialDetectionResult(v);
569 } catch (IOException e) {
570 logger.trace("Failed to execute a native ping for ip {}", hostname, e);
571 } catch (InterruptedException e) {
572 // This can be ignored, the thread will end anyway
576 private double getLatency(PingResult pingResult, boolean preferResponseTimeAsLatency) {
577 logger.debug("Getting latency from ping result {} using latency mode {}", pingResult,
578 preferResponseTimeAsLatency);
579 // Execution time is always set and this value is also the default. So lets use it first.
580 double latency = pingResult.getExecutionTimeInMS();
582 if (preferResponseTimeAsLatency && pingResult.getResponseTimeInMS().isPresent()) {
583 latency = pingResult.getResponseTimeInMS().get();
590 public void dhcpRequestReceived(String ipAddress) {
591 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.DHCP_REQUEST, 0);
592 updateListener.partialDetectionResult(v);
596 * Start/Restart a fixed scheduled runner to update the devices reach-ability state.
598 * @param scheduledExecutorService A scheduler to run pings periodically.
600 public void startAutomaticRefresh(ScheduledExecutorService scheduledExecutorService) {
601 ScheduledFuture<?> future = refreshJob;
602 if (future != null && !future.isDone()) {
605 refreshJob = scheduledExecutorService.scheduleWithFixedDelay(() -> performPresenceDetection(true), 0,
606 refreshIntervalInMS, TimeUnit.MILLISECONDS);
610 * Return true if automatic refreshing is enabled.
612 public boolean isAutomaticRefreshing() {
613 return refreshJob != null;
617 * Stop automatic refreshing.
619 public void stopAutomaticRefresh() {
620 ScheduledFuture<?> future = refreshJob;
621 if (future != null && !future.isDone()) {
625 if (cachedDestination != null) {
626 disableDHCPListen(cachedDestination);
631 * Enables listing for dhcp packets to figure out if devices have entered the network. This does not work
632 * for iOS devices. The hostname of this network service object will be registered to the dhcp request packet
633 * listener if enabled and unregistered otherwise.
635 * @param destinationAddress the InetAddress to listen for.
637 private void enableDHCPListen(InetAddress destinationAddress) {
639 if (DHCPListenService.register(destinationAddress.getHostAddress(), this).isUseUnprevilegedPort()) {
640 dhcpState = "No access right for port 67. Bound to port 6767 instead. Port forwarding necessary!";
642 dhcpState = "Running normally";
644 } catch (SocketException e) {
645 logger.warn("Cannot use DHCP sniffing.", e);
646 useDHCPsniffing = false;
647 dhcpState = "Cannot use DHCP sniffing: " + e.getLocalizedMessage();
651 private void disableDHCPListen(@Nullable InetAddress destinationAddress) {
652 if (destinationAddress != null) {
653 DHCPListenService.unregister(destinationAddress.getHostAddress());