2 * Copyright (c) 2010-2023 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.DHCPPacketListenerServer;
33 import org.openhab.binding.network.internal.dhcp.IPRequestReceivedCallback;
34 import org.openhab.binding.network.internal.toberemoved.cache.ExpiringCacheAsync;
35 import org.openhab.binding.network.internal.utils.NetworkUtils;
36 import org.openhab.binding.network.internal.utils.NetworkUtils.ArpPingUtilEnum;
37 import org.openhab.binding.network.internal.utils.NetworkUtils.IpPingMethodEnum;
38 import org.openhab.binding.network.internal.utils.PingResult;
39 import org.openhab.core.cache.ExpiringCache;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
44 * The {@link PresenceDetection} handles the connection to the Device
46 * @author Marc Mettke - Initial contribution
47 * @author David Gräff, 2017 - Rewritten
48 * @author Jan N. Klug - refactored host name resolution
51 public class PresenceDetection implements IPRequestReceivedCallback {
53 private 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 private 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 private 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 private 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 InetAddress cached = cachedDestination;
116 if (!destinationAddress.equals(cached)) {
117 logger.trace("host name resolved to other address, (re-)setup presence detection");
118 setUseArpPing(true, destinationAddress);
119 if (useDHCPsniffing) {
120 if (cached != null) {
121 disableDHCPListen(cached);
123 enableDHCPListen(destinationAddress);
125 cachedDestination = destinationAddress;
127 return destinationAddress;
128 } catch (UnknownHostException e) {
129 logger.trace("hostname resolution failed");
130 InetAddress cached = cachedDestination;
131 if (cached != null) {
132 disableDHCPListen(cached);
133 cachedDestination = null;
140 public void setServicePorts(Set<Integer> ports) {
141 this.tcpPorts = ports;
144 public void setUseDhcpSniffing(boolean enable) {
145 this.useDHCPsniffing = enable;
148 public void setRefreshInterval(long refreshInterval) {
149 this.refreshIntervalInMS = refreshInterval;
152 public void setTimeout(int timeout) {
153 this.timeoutInMS = timeout;
156 public void setPreferResponseTimeAsLatency(boolean preferResponseTimeAsLatency) {
157 this.preferResponseTimeAsLatency = preferResponseTimeAsLatency;
161 * Sets the ping method. This method will perform a feature test. If SYSTEM_PING
162 * does not work on this system, JAVA_PING will be used instead.
164 * @param useSystemPing Set to true to use a system ping method, false to use java ping and null to disable ICMP
167 public void setUseIcmpPing(@Nullable Boolean useSystemPing) {
168 if (useSystemPing == null) {
169 ipPingState = "Disabled";
171 } else if (useSystemPing) {
172 final IpPingMethodEnum pingMethod = networkUtils.determinePingMethod();
173 this.pingMethod = pingMethod;
174 ipPingState = pingMethod == IpPingMethodEnum.JAVA_PING ? "System ping feature test failed. Using Java ping"
177 pingMethod = IpPingMethodEnum.JAVA_PING;
178 ipPingState = "Java ping";
183 * Enables or disables ARP pings. Will be automatically disabled if the destination
184 * is not an IPv4 address. If the feature test for the native arping utility fails,
185 * it will be disabled as well.
187 * @param enable Enable or disable ARP ping
188 * @param destinationAddress target ip address
190 private void setUseArpPing(boolean enable, @Nullable InetAddress destinationAddress) {
191 if (!enable || arpPingUtilPath.isEmpty()) {
192 arpPingMethod = ArpPingUtilEnum.DISABLED;
193 } else if (!(destinationAddress instanceof Inet4Address)) {
194 arpPingMethod = ArpPingUtilEnum.DISABLED_INVALID_IP;
199 * sets the path to arp ping
201 * @param enable Enable or disable ARP ping
202 * @param arpPingUtilPath enableDHCPListen(useDHCPsniffing);
204 public void setUseArpPing(boolean enable, String arpPingUtilPath, ArpPingUtilEnum arpPingUtilMethod) {
205 setUseArpPing(enable, destination.getValue());
206 this.arpPingUtilPath = arpPingUtilPath;
207 this.arpPingMethod = arpPingUtilMethod;
210 public String getArpPingState() {
211 return arpPingMethod.description;
214 public String getIPPingState() {
218 public String getDhcpState() {
223 * Return true if the device presence detection is performed for an iOS device
224 * like iPhone or iPads. An additional port knock is performed before a ping.
226 public boolean isIOSdevice() {
231 * Set to true if the device presence detection should be performed for an iOS device
232 * like iPhone or iPads. An additional port knock is performed before a ping.
234 public void setIOSDevice(boolean value) {
239 * Return the last seen value in milliseconds based on {@link System.currentTimeMillis()} or 0 if not seen yet.
241 public long getLastSeen() {
246 * Return asynchronously the value of the presence detection as a PresenceDetectionValue.
248 * @param callback A callback with the PresenceDetectionValue. The callback may
249 * not happen immediately if the cached value expired, but as soon as a new
250 * discovery took place.
252 public void getValue(Consumer<PresenceDetectionValue> callback) {
253 cache.getValue(callback);
256 public ExecutorService getThreadsFor(int threadCount) {
257 return Executors.newFixedThreadPool(threadCount);
261 * Perform a presence detection with ICMP-, ARP ping and
262 * TCP connection attempts simultaneously. A fixed thread pool will be created with as many
263 * thread as necessary to perform all tests at once.
265 * This is a NO-OP, if there is already an ongoing detection or if the cached value
266 * is not expired yet.
268 * Please be aware of the following restrictions:
269 * - ARP pings are only executed on IPv4 addresses.
270 * - Non system / Java pings are not recommended at all
271 * (not interruptible, useless TCP echo service fall back)
273 * @param waitForDetectionToFinish If you want to synchronously wait for the result, set this to true
274 * @return Return true if a presence detection is performed and false otherwise.
276 public boolean performPresenceDetection(boolean waitForDetectionToFinish) {
277 if (executorService != null) {
279 "There is already an ongoing presence discovery for {} and a new one was issued by the scheduler! TCP Port {}",
284 if (!cache.isExpired()) {
288 Set<String> interfaceNames = null;
291 detectionChecks = tcpPorts.size();
292 if (pingMethod != null) {
293 detectionChecks += 1;
295 if (arpPingMethod.canProceed) {
296 interfaceNames = networkUtils.getInterfaceNames();
297 detectionChecks += interfaceNames.size();
300 if (detectionChecks == 0) {
304 final ExecutorService executorService = getThreadsFor(detectionChecks);
305 this.executorService = executorService;
307 for (Integer tcpPort : tcpPorts) {
308 executorService.execute(() -> {
309 Thread.currentThread().setName("presenceDetectionTCP_" + hostname + " " + String.valueOf(tcpPort));
310 performServicePing(tcpPort);
315 // ARP ping for IPv4 addresses. Use single executor for Windows tool and
316 // each own executor for each network interface for other tools
317 if (arpPingMethod == ArpPingUtilEnum.ELI_FULKERSON_ARP_PING_FOR_WINDOWS) {
318 executorService.execute(() -> {
319 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " ");
320 // arp-ping.exe tool capable of handling multiple interfaces by itself
324 } else if (interfaceNames != null) {
325 for (final String interfaceName : interfaceNames) {
326 executorService.execute(() -> {
327 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " " + interfaceName);
328 performARPping(interfaceName);
335 if (pingMethod != null) {
336 executorService.execute(() -> {
337 if (pingMethod != IpPingMethodEnum.JAVA_PING) {
338 Thread.currentThread().setName("presenceDetectionICMP_" + hostname);
347 if (waitForDetectionToFinish) {
348 waitForPresenceDetection();
355 * Calls updateListener.finalDetectionResult() with a final result value.
356 * Safe to be called from different threads. After a call to this method,
357 * the presence detection process is finished and all threads are forcefully
360 private synchronized void submitFinalResult() {
361 // Do nothing if we are not in a detection process
362 ExecutorService service = executorService;
363 if (service == null) {
366 // Finish the detection process
367 service.shutdownNow();
368 executorService = null;
371 PresenceDetectionValue v;
373 // The cache will be expired by now if cache_time < timeoutInMS. But the device might be actually reachable.
374 // Therefore use lastSeenInMS here and not cache.isExpired() to determine if we got a ping response.
375 if (lastSeenInMS + timeoutInMS + 100 < System.currentTimeMillis()) {
376 // We haven't seen the device in the detection process
377 v = new PresenceDetectionValue(hostname, -1);
379 // Make the cache valid again and submit the value.
380 v = cache.getExpiredValue();
384 if (!v.isReachable()) {
385 // if target can't be reached, check if name resolution need to be updated
386 destination.invalidateValue();
388 updateListener.finalDetectionResult(v);
392 * This method is called after each individual check and increases a check counter.
393 * If the counter equals the total checks,the final result is submitted. This will
394 * happen way before the "timeoutInMS", if all checks were successful.
397 private synchronized void checkIfFinished() {
399 if (currentCheck < detectionChecks) {
406 * Waits for the presence detection threads to finish. Returns immediately
407 * if no presence detection is performed right now.
409 public void waitForPresenceDetection() {
410 ExecutorService service = executorService;
411 if (service == null) {
415 // We may get interrupted here by cancelRefreshJob().
416 service.awaitTermination(timeoutInMS + 100, TimeUnit.MILLISECONDS);
418 } catch (InterruptedException e) {
419 Thread.currentThread().interrupt(); // Reset interrupt flag
420 service.shutdownNow();
421 executorService = null;
426 * If the cached PresenceDetectionValue has not expired yet, the cached version
427 * is returned otherwise a new reachable PresenceDetectionValue is created with
430 * It is safe to call this method from multiple threads. The returned PresenceDetectionValue
431 * might be still be altered in other threads though.
433 * @param type The detection type
434 * @return The non expired or a new instance of PresenceDetectionValue.
436 synchronized PresenceDetectionValue updateReachableValue(PresenceDetectionType type, double latency) {
437 lastSeenInMS = System.currentTimeMillis();
438 PresenceDetectionValue v;
439 if (cache.isExpired()) {
440 v = new PresenceDetectionValue(hostname, 0);
442 v = cache.getExpiredValue();
444 v.updateLatency(latency);
450 protected void performServicePing(int tcpPort) {
451 logger.trace("Perform TCP presence detection for {} on port: {}", hostname, tcpPort);
453 InetAddress destinationAddress = destination.getValue();
454 if (destinationAddress != null) {
455 networkUtils.servicePing(destinationAddress.getHostAddress(), tcpPort, timeoutInMS).ifPresent(o -> {
457 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.TCP_CONNECTION,
458 getLatency(o, preferResponseTimeAsLatency));
459 v.addReachableTcpService(tcpPort);
460 updateListener.partialDetectionResult(v);
464 } catch (IOException e) {
465 // This should not happen and might be a user configuration issue, we log a warning message therefore.
466 logger.warn("Could not create a socket connection", e);
471 * Performs an "ARP ping" (ARP request) on the given interface.
472 * If it is an iOS device, the {@see NetworkUtils.wakeUpIOS()} method is
473 * called before performing the ARP ping.
475 * @param interfaceName The interface name. You can request a list of interface names
476 * from {@see NetworkUtils.getInterfaceNames()} for example.
478 protected void performARPping(String interfaceName) {
480 logger.trace("Perform ARP ping presence detection for {} on interface: {}", hostname, interfaceName);
481 InetAddress destinationAddress = destination.getValue();
482 if (destinationAddress == null) {
486 networkUtils.wakeUpIOS(destinationAddress);
490 networkUtils.nativeARPPing(arpPingMethod, arpPingUtilPath, interfaceName,
491 destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
493 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ARP_PING,
494 getLatency(o, preferResponseTimeAsLatency));
495 updateListener.partialDetectionResult(v);
498 } catch (IOException e) {
499 logger.trace("Failed to execute an arp ping for ip {}", hostname, e);
500 } catch (InterruptedException ignored) {
501 // This can be ignored, the thread will end anyway
506 * Performs a java ping. It is not recommended to use this, as it is not interruptible,
507 * and will not work on windows systems reliably and will fall back from ICMP pings to
508 * the TCP echo service on port 7 which barely no device or server supports nowadays.
509 * (https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/InetAddress.html#isReachable%28int%29)
511 protected void performJavaPing() {
512 logger.trace("Perform java ping presence detection for {}", hostname);
514 InetAddress destinationAddress = destination.getValue();
515 if (destinationAddress == null) {
519 networkUtils.javaPing(timeoutInMS, destinationAddress).ifPresent(o -> {
521 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
522 getLatency(o, preferResponseTimeAsLatency));
523 updateListener.partialDetectionResult(v);
528 protected void performSystemPing() {
530 logger.trace("Perform native ping presence detection for {}", hostname);
531 InetAddress destinationAddress = destination.getValue();
532 if (destinationAddress == null) {
536 networkUtils.nativePing(pingMethod, destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
538 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
539 getLatency(o, preferResponseTimeAsLatency));
540 updateListener.partialDetectionResult(v);
543 } catch (IOException e) {
544 logger.trace("Failed to execute a native ping for ip {}", hostname, e);
545 } catch (InterruptedException e) {
546 // This can be ignored, the thread will end anyway
550 private double getLatency(PingResult pingResult, boolean preferResponseTimeAsLatency) {
551 logger.debug("Getting latency from ping result {} using latency mode {}", pingResult,
552 preferResponseTimeAsLatency);
553 // Execution time is always set and this value is also the default. So lets use it first.
554 double latency = pingResult.getExecutionTimeInMS();
556 if (preferResponseTimeAsLatency && pingResult.getResponseTimeInMS().isPresent()) {
557 latency = pingResult.getResponseTimeInMS().get();
564 public void dhcpRequestReceived(String ipAddress) {
565 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.DHCP_REQUEST, 0);
566 updateListener.partialDetectionResult(v);
570 * Start/Restart a fixed scheduled runner to update the devices reach-ability state.
572 * @param scheduledExecutorService A scheduler to run pings periodically.
574 public void startAutomaticRefresh(ScheduledExecutorService scheduledExecutorService) {
575 ScheduledFuture<?> future = refreshJob;
576 if (future != null && !future.isDone()) {
579 refreshJob = scheduledExecutorService.scheduleWithFixedDelay(() -> performPresenceDetection(true), 0,
580 refreshIntervalInMS, TimeUnit.MILLISECONDS);
584 * Return true if automatic refreshing is enabled.
586 public boolean isAutomaticRefreshing() {
587 return refreshJob != null;
591 * Stop automatic refreshing.
593 public void stopAutomaticRefresh() {
594 ScheduledFuture<?> future = refreshJob;
595 if (future != null && !future.isDone()) {
599 InetAddress cached = cachedDestination;
600 if (cached != null) {
601 disableDHCPListen(cached);
606 * Enables listing for dhcp packets to figure out if devices have entered the network. This does not work
607 * for iOS devices. The hostname of this network service object will be registered to the dhcp request packet
608 * listener if enabled and unregistered otherwise.
610 * @param destinationAddress the InetAddress to listen for.
612 private void enableDHCPListen(InetAddress destinationAddress) {
614 DHCPPacketListenerServer listener = DHCPListenService.register(destinationAddress.getHostAddress(), this);
615 dhcpState = String.format("Bound to port %d - %s", listener.getCurrentPort(),
616 (listener.usingPrivilegedPort() ? "Running normally" : "Port forwarding necessary !"));
617 } catch (SocketException e) {
618 dhcpState = String.format("Cannot use DHCP sniffing: %s", e.getMessage());
619 logger.warn("{}", dhcpState);
620 useDHCPsniffing = false;
624 private void disableDHCPListen(InetAddress destinationAddress) {
625 DHCPListenService.unregister(destinationAddress.getHostAddress());