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 ipPingState = "Disabled";
61 protected String arpPingUtilPath = "";
62 protected ArpPingUtilEnum arpPingMethod = ArpPingUtilEnum.DISABLED;
63 protected @Nullable IpPingMethodEnum pingMethod = null;
64 private boolean iosDevice;
65 private Set<Integer> tcpPorts = new HashSet<>();
67 private long refreshIntervalInMS = 60000;
68 private int timeoutInMS = 5000;
69 private long lastSeenInMS;
71 private @NonNullByDefault({}) String hostname;
72 private @NonNullByDefault({}) ExpiringCache<@Nullable InetAddress> destination;
73 private @Nullable InetAddress cachedDestination = null;
75 public boolean preferResponseTimeAsLatency;
77 /// State variables (cannot be final because of test dependency injections)
78 ExpiringCacheAsync<PresenceDetectionValue> cache;
79 private final PresenceDetectionListener updateListener;
80 private @Nullable ScheduledFuture<?> refreshJob;
81 protected @Nullable ExecutorService executorService;
82 private String dhcpState = "off";
83 Integer currentCheck = 0;
86 public PresenceDetection(final PresenceDetectionListener updateListener, int cacheDeviceStateTimeInMS)
87 throws IllegalArgumentException {
88 this.updateListener = updateListener;
89 cache = new ExpiringCacheAsync<>(cacheDeviceStateTimeInMS, () -> {
90 performPresenceDetection(false);
94 public @Nullable String getHostname() {
98 public Set<Integer> getServicePorts() {
102 public long getRefreshInterval() {
103 return refreshIntervalInMS;
106 public int getTimeout() {
110 public void setHostname(String hostname) {
111 this.hostname = hostname;
112 this.destination = new ExpiringCache<>(DESTINATION_TTL, () -> {
114 InetAddress destinationAddress = InetAddress.getByName(hostname);
115 if (!destinationAddress.equals(cachedDestination)) {
116 logger.trace("host name resolved to other address, (re-)setup presence detection");
117 setUseArpPing(true, destinationAddress);
118 if (useDHCPsniffing) {
119 if (cachedDestination != null) {
120 disableDHCPListen(cachedDestination);
122 enableDHCPListen(destinationAddress);
124 cachedDestination = destinationAddress;
126 return destinationAddress;
127 } catch (UnknownHostException e) {
128 logger.trace("hostname resolution failed");
129 if (cachedDestination != null) {
130 disableDHCPListen(cachedDestination);
131 cachedDestination = null;
138 public void setServicePorts(Set<Integer> ports) {
139 this.tcpPorts = ports;
142 public void setUseDhcpSniffing(boolean enable) {
143 this.useDHCPsniffing = enable;
146 public void setRefreshInterval(long refreshInterval) {
147 this.refreshIntervalInMS = refreshInterval;
150 public void setTimeout(int timeout) {
151 this.timeoutInMS = timeout;
154 public void setPreferResponseTimeAsLatency(boolean preferResponseTimeAsLatency) {
155 this.preferResponseTimeAsLatency = preferResponseTimeAsLatency;
159 * Sets the ping method. This method will perform a feature test. If SYSTEM_PING
160 * does not work on this system, JAVA_PING will be used instead.
162 * @param useSystemPing Set to true to use a system ping method, false to use java ping and null to disable ICMP
165 public void setUseIcmpPing(@Nullable Boolean useSystemPing) {
166 if (useSystemPing == null) {
167 ipPingState = "Disabled";
169 } else if (useSystemPing) {
170 final IpPingMethodEnum pingMethod = networkUtils.determinePingMethod();
171 this.pingMethod = pingMethod;
172 ipPingState = pingMethod == IpPingMethodEnum.JAVA_PING ? "System ping feature test failed. Using Java ping"
175 pingMethod = IpPingMethodEnum.JAVA_PING;
176 ipPingState = "Java ping";
181 * Enables or disables ARP pings. Will be automatically disabled if the destination
182 * is not an IPv4 address. If the feature test for the native arping utility fails,
183 * it will be disabled as well.
185 * @param enable Enable or disable ARP ping
186 * @param destinationAddress target ip address
188 private void setUseArpPing(boolean enable, @Nullable InetAddress destinationAddress) {
189 if (!enable || arpPingUtilPath.isEmpty()) {
190 arpPingMethod = ArpPingUtilEnum.DISABLED;
191 } else if (!(destinationAddress instanceof Inet4Address)) {
192 arpPingMethod = ArpPingUtilEnum.DISABLED_INVALID_IP;
197 * sets the path to arp ping
199 * @param enable Enable or disable ARP ping
200 * @param arpPingUtilPath enableDHCPListen(useDHCPsniffing);
202 public void setUseArpPing(boolean enable, String arpPingUtilPath, ArpPingUtilEnum arpPingUtilMethod) {
203 setUseArpPing(enable, destination.getValue());
204 this.arpPingUtilPath = arpPingUtilPath;
205 this.arpPingMethod = arpPingUtilMethod;
208 public String getArpPingState() {
209 return arpPingMethod.description;
212 public String getIPPingState() {
216 public String getDhcpState() {
221 * Return true if the device presence detection is performed for an iOS device
222 * like iPhone or iPads. An additional port knock is performed before a ping.
224 public boolean isIOSdevice() {
229 * Set to true if the device presence detection should be performed for an iOS device
230 * like iPhone or iPads. An additional port knock is performed before a ping.
232 public void setIOSDevice(boolean value) {
237 * Return the last seen value in milliseconds based on {@link System.currentTimeMillis()} or 0 if not seen yet.
239 public long getLastSeen() {
244 * Return asynchronously the value of the presence detection as a PresenceDetectionValue.
246 * @param callback A callback with the PresenceDetectionValue. The callback may
247 * not happen immediately if the cached value expired, but as soon as a new
248 * discovery took place.
250 public void getValue(Consumer<PresenceDetectionValue> callback) {
251 cache.getValue(callback);
254 public ExecutorService getThreadsFor(int threadCount) {
255 return Executors.newFixedThreadPool(threadCount);
259 * Perform a presence detection with ICMP-, ARP ping and
260 * TCP connection attempts simultaneously. A fixed thread pool will be created with as many
261 * thread as necessary to perform all tests at once.
263 * This is a NO-OP, if there is already an ongoing detection or if the cached value
264 * is not expired yet.
266 * Please be aware of the following restrictions:
267 * - ARP pings are only executed on IPv4 addresses.
268 * - Non system / Java pings are not recommended at all
269 * (not interruptible, useless TCP echo service fall back)
271 * @param waitForDetectionToFinish If you want to synchronously wait for the result, set this to true
272 * @return Return true if a presence detection is performed and false otherwise.
274 public boolean performPresenceDetection(boolean waitForDetectionToFinish) {
275 if (executorService != null) {
277 "There is already an ongoing presence discovery for {} and a new one was issued by the scheduler! TCP Port {}",
282 if (!cache.isExpired()) {
286 Set<String> interfaceNames = null;
289 detectionChecks = tcpPorts.size();
290 if (pingMethod != null) {
291 detectionChecks += 1;
293 if (arpPingMethod.canProceed) {
294 interfaceNames = networkUtils.getInterfaceNames();
295 detectionChecks += interfaceNames.size();
298 if (detectionChecks == 0) {
302 final ExecutorService executorService = getThreadsFor(detectionChecks);
303 this.executorService = executorService;
305 for (Integer tcpPort : tcpPorts) {
306 executorService.execute(() -> {
307 Thread.currentThread().setName("presenceDetectionTCP_" + hostname + " " + String.valueOf(tcpPort));
308 performServicePing(tcpPort);
313 // ARP ping for IPv4 addresses. Use single executor for Windows tool and
314 // each own executor for each network interface for other tools
315 if (arpPingMethod == ArpPingUtilEnum.ELI_FULKERSON_ARP_PING_FOR_WINDOWS) {
316 executorService.execute(() -> {
317 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " ");
318 // arp-ping.exe tool capable of handling multiple interfaces by itself
322 } else if (interfaceNames != null) {
323 for (final String interfaceName : interfaceNames) {
324 executorService.execute(() -> {
325 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " " + interfaceName);
326 performARPping(interfaceName);
333 if (pingMethod != null) {
334 executorService.execute(() -> {
335 if (pingMethod != IpPingMethodEnum.JAVA_PING) {
336 Thread.currentThread().setName("presenceDetectionICMP_" + hostname);
345 if (waitForDetectionToFinish) {
346 waitForPresenceDetection();
353 * Calls updateListener.finalDetectionResult() with a final result value.
354 * Safe to be called from different threads. After a call to this method,
355 * the presence detection process is finished and all threads are forcefully
358 private synchronized void submitFinalResult() {
359 // Do nothing if we are not in a detection process
360 ExecutorService service = executorService;
361 if (service == null) {
364 // Finish the detection process
365 service.shutdownNow();
366 executorService = null;
369 PresenceDetectionValue v;
371 // The cache will be expired by now if cache_time < timeoutInMS. But the device might be actually reachable.
372 // Therefore use lastSeenInMS here and not cache.isExpired() to determine if we got a ping response.
373 if (lastSeenInMS + timeoutInMS + 100 < System.currentTimeMillis()) {
374 // We haven't seen the device in the detection process
375 v = new PresenceDetectionValue(hostname, -1);
377 // Make the cache valid again and submit the value.
378 v = cache.getExpiredValue();
382 if (!v.isReachable()) {
383 // if target can't be reached, check if name resolution need to be updated
384 destination.invalidateValue();
386 updateListener.finalDetectionResult(v);
390 * This method is called after each individual check and increases a check counter.
391 * If the counter equals the total checks,the final result is submitted. This will
392 * happen way before the "timeoutInMS", if all checks were successful.
395 private synchronized void checkIfFinished() {
397 if (currentCheck < detectionChecks) {
404 * Waits for the presence detection threads to finish. Returns immediately
405 * if no presence detection is performed right now.
407 public void waitForPresenceDetection() {
408 ExecutorService service = executorService;
409 if (service == null) {
413 // We may get interrupted here by cancelRefreshJob().
414 service.awaitTermination(timeoutInMS + 100, TimeUnit.MILLISECONDS);
416 } catch (InterruptedException e) {
417 Thread.currentThread().interrupt(); // Reset interrupt flag
418 service.shutdownNow();
419 executorService = null;
424 * If the cached PresenceDetectionValue has not expired yet, the cached version
425 * is returned otherwise a new reachable PresenceDetectionValue is created with
428 * It is safe to call this method from multiple threads. The returned PresenceDetectionValue
429 * might be still be altered in other threads though.
431 * @param type The detection type
432 * @return The non expired or a new instance of PresenceDetectionValue.
434 synchronized PresenceDetectionValue updateReachableValue(PresenceDetectionType type, double latency) {
435 lastSeenInMS = System.currentTimeMillis();
436 PresenceDetectionValue v;
437 if (cache.isExpired()) {
438 v = new PresenceDetectionValue(hostname, 0);
440 v = cache.getExpiredValue();
442 v.updateLatency(latency);
448 protected void performServicePing(int tcpPort) {
449 logger.trace("Perform TCP presence detection for {} on port: {}", hostname, tcpPort);
451 InetAddress destinationAddress = destination.getValue();
452 if (destinationAddress != null) {
453 networkUtils.servicePing(destinationAddress.getHostAddress(), tcpPort, timeoutInMS).ifPresent(o -> {
455 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.TCP_CONNECTION,
456 getLatency(o, preferResponseTimeAsLatency));
457 v.addReachableTcpService(tcpPort);
458 updateListener.partialDetectionResult(v);
462 } catch (IOException e) {
463 // This should not happen and might be a user configuration issue, we log a warning message therefore.
464 logger.warn("Could not create a socket connection", e);
469 * Performs an "ARP ping" (ARP request) on the given interface.
470 * If it is an iOS device, the {@see NetworkUtils.wakeUpIOS()} method is
471 * called before performing the ARP ping.
473 * @param interfaceName The interface name. You can request a list of interface names
474 * from {@see NetworkUtils.getInterfaceNames()} for example.
476 protected void performARPping(String interfaceName) {
478 logger.trace("Perform ARP ping presence detection for {} on interface: {}", hostname, interfaceName);
479 InetAddress destinationAddress = destination.getValue();
480 if (destinationAddress == null) {
484 networkUtils.wakeUpIOS(destinationAddress);
488 networkUtils.nativeARPPing(arpPingMethod, arpPingUtilPath, interfaceName,
489 destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
491 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ARP_PING,
492 getLatency(o, preferResponseTimeAsLatency));
493 updateListener.partialDetectionResult(v);
496 } catch (IOException e) {
497 logger.trace("Failed to execute an arp ping for ip {}", hostname, e);
498 } catch (InterruptedException ignored) {
499 // This can be ignored, the thread will end anyway
504 * Performs a java ping. It is not recommended to use this, as it is not interruptible,
505 * and will not work on windows systems reliably and will fall back from ICMP pings to
506 * the TCP echo service on port 7 which barely no device or server supports nowadays.
507 * (http://docs.oracle.com/javase/7/docs/api/java/net/InetAddress.html#isReachable%28int%29)
509 protected void performJavaPing() {
510 logger.trace("Perform java ping presence detection for {}", hostname);
512 InetAddress destinationAddress = destination.getValue();
513 if (destinationAddress == null) {
517 networkUtils.javaPing(timeoutInMS, destinationAddress).ifPresent(o -> {
519 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
520 getLatency(o, preferResponseTimeAsLatency));
521 updateListener.partialDetectionResult(v);
526 protected void performSystemPing() {
528 logger.trace("Perform native ping presence detection for {}", hostname);
529 InetAddress destinationAddress = destination.getValue();
530 if (destinationAddress == null) {
534 networkUtils.nativePing(pingMethod, destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
536 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
537 getLatency(o, preferResponseTimeAsLatency));
538 updateListener.partialDetectionResult(v);
541 } catch (IOException e) {
542 logger.trace("Failed to execute a native ping for ip {}", hostname, e);
543 } catch (InterruptedException e) {
544 // This can be ignored, the thread will end anyway
548 private double getLatency(PingResult pingResult, boolean preferResponseTimeAsLatency) {
549 logger.debug("Getting latency from ping result {} using latency mode {}", pingResult,
550 preferResponseTimeAsLatency);
551 // Execution time is always set and this value is also the default. So lets use it first.
552 double latency = pingResult.getExecutionTimeInMS();
554 if (preferResponseTimeAsLatency && pingResult.getResponseTimeInMS().isPresent()) {
555 latency = pingResult.getResponseTimeInMS().get();
562 public void dhcpRequestReceived(String ipAddress) {
563 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.DHCP_REQUEST, 0);
564 updateListener.partialDetectionResult(v);
568 * Start/Restart a fixed scheduled runner to update the devices reach-ability state.
570 * @param scheduledExecutorService A scheduler to run pings periodically.
572 public void startAutomaticRefresh(ScheduledExecutorService scheduledExecutorService) {
573 ScheduledFuture<?> future = refreshJob;
574 if (future != null && !future.isDone()) {
577 refreshJob = scheduledExecutorService.scheduleWithFixedDelay(() -> performPresenceDetection(true), 0,
578 refreshIntervalInMS, TimeUnit.MILLISECONDS);
582 * Return true if automatic refreshing is enabled.
584 public boolean isAutomaticRefreshing() {
585 return refreshJob != null;
589 * Stop automatic refreshing.
591 public void stopAutomaticRefresh() {
592 ScheduledFuture<?> future = refreshJob;
593 if (future != null && !future.isDone()) {
597 if (cachedDestination != null) {
598 disableDHCPListen(cachedDestination);
603 * Enables listing for dhcp packets to figure out if devices have entered the network. This does not work
604 * for iOS devices. The hostname of this network service object will be registered to the dhcp request packet
605 * listener if enabled and unregistered otherwise.
607 * @param destinationAddress the InetAddress to listen for.
609 private void enableDHCPListen(InetAddress destinationAddress) {
611 if (DHCPListenService.register(destinationAddress.getHostAddress(), this).isUseUnprevilegedPort()) {
612 dhcpState = "No access right for port 67. Bound to port 6767 instead. Port forwarding necessary!";
614 dhcpState = "Running normally";
616 } catch (SocketException e) {
617 logger.warn("Cannot use DHCP sniffing.", e);
618 useDHCPsniffing = false;
619 dhcpState = "Cannot use DHCP sniffing: " + e.getLocalizedMessage();
623 private void disableDHCPListen(@Nullable InetAddress destinationAddress) {
624 if (destinationAddress != null) {
625 DHCPListenService.unregister(destinationAddress.getHostAddress());