2 * Copyright (c) 2010-2024 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 static org.openhab.binding.network.internal.PresenceDetectionType.*;
17 import java.io.IOException;
18 import java.net.Inet4Address;
19 import java.net.InetAddress;
20 import java.net.SocketException;
21 import java.net.UnknownHostException;
22 import java.time.Duration;
23 import java.time.Instant;
24 import java.util.ArrayList;
25 import java.util.HashSet;
26 import java.util.List;
28 import java.util.concurrent.CompletableFuture;
29 import java.util.concurrent.ExecutionException;
30 import java.util.concurrent.ExecutorService;
31 import java.util.concurrent.Executors;
32 import java.util.concurrent.ScheduledExecutorService;
33 import java.util.concurrent.ScheduledFuture;
34 import java.util.concurrent.TimeUnit;
35 import java.util.function.Consumer;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.openhab.binding.network.internal.dhcp.DHCPListenService;
40 import org.openhab.binding.network.internal.dhcp.DHCPPacketListenerServer;
41 import org.openhab.binding.network.internal.dhcp.IPRequestReceivedCallback;
42 import org.openhab.binding.network.internal.utils.NetworkUtils;
43 import org.openhab.binding.network.internal.utils.NetworkUtils.ArpPingUtilEnum;
44 import org.openhab.binding.network.internal.utils.NetworkUtils.IpPingMethodEnum;
45 import org.openhab.binding.network.internal.utils.PingResult;
46 import org.openhab.core.cache.ExpiringCache;
47 import org.openhab.core.cache.ExpiringCacheAsync;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
52 * The {@link PresenceDetection} handles the connection to the Device.
54 * @author Marc Mettke - Initial contribution
55 * @author David Gräff, 2017 - Rewritten
56 * @author Jan N. Klug - refactored host name resolution
57 * @author Wouter Born - Reuse ExpiringCacheAsync from Core
60 public class PresenceDetection implements IPRequestReceivedCallback {
62 private static final Duration DESTINATION_TTL = Duration.ofMinutes(5);
64 NetworkUtils networkUtils = new NetworkUtils();
65 private final Logger logger = LoggerFactory.getLogger(PresenceDetection.class);
67 /// Configuration variables
68 private boolean useDHCPsniffing = false;
69 private String ipPingState = "Disabled";
70 protected String arpPingUtilPath = "";
71 private ArpPingUtilEnum arpPingMethod = ArpPingUtilEnum.DISABLED;
72 protected @Nullable IpPingMethodEnum pingMethod = null;
73 private boolean iosDevice;
74 private Set<Integer> tcpPorts = new HashSet<>();
76 private Duration refreshInterval = Duration.ofMinutes(1);
77 private Duration timeout = Duration.ofSeconds(5);
78 private @Nullable Instant lastSeen;
80 private @NonNullByDefault({}) String hostname;
81 private @NonNullByDefault({}) ExpiringCache<@Nullable InetAddress> destination;
82 private @Nullable InetAddress cachedDestination;
84 private boolean preferResponseTimeAsLatency;
86 // State variables (cannot be final because of test dependency injections)
87 ExpiringCacheAsync<PresenceDetectionValue> cache;
89 private final PresenceDetectionListener updateListener;
90 private ScheduledExecutorService scheduledExecutorService;
92 private Set<String> networkInterfaceNames = Set.of();
93 private @Nullable ScheduledFuture<?> refreshJob;
94 protected @Nullable ExecutorService detectionExecutorService;
95 private String dhcpState = "off";
97 private String lastReachableNetworkInterfaceName = "";
99 public PresenceDetection(final PresenceDetectionListener updateListener,
100 ScheduledExecutorService scheduledExecutorService, Duration cacheDeviceStateTime)
101 throws IllegalArgumentException {
102 this.updateListener = updateListener;
103 this.scheduledExecutorService = scheduledExecutorService;
104 cache = new ExpiringCacheAsync<>(cacheDeviceStateTime);
107 public @Nullable String getHostname() {
111 public Set<Integer> getServicePorts() {
115 public Duration getRefreshInterval() {
116 return refreshInterval;
119 public Duration getTimeout() {
123 public void setHostname(String hostname) {
124 this.hostname = hostname;
125 this.destination = new ExpiringCache<>(DESTINATION_TTL, () -> {
127 InetAddress destinationAddress = InetAddress.getByName(hostname);
128 InetAddress cached = cachedDestination;
129 if (!destinationAddress.equals(cached)) {
130 logger.trace("Hostname {} resolved to other address {}, (re-)setup presence detection", hostname,
132 setUseArpPing(true, destinationAddress);
133 if (useDHCPsniffing) {
134 if (cached != null) {
135 disableDHCPListen(cached);
137 enableDHCPListen(destinationAddress);
139 cachedDestination = destinationAddress;
141 return destinationAddress;
142 } catch (UnknownHostException e) {
143 logger.trace("Hostname resolution for {} failed", hostname);
144 InetAddress cached = cachedDestination;
145 if (cached != null) {
146 disableDHCPListen(cached);
147 cachedDestination = null;
154 public void setNetworkInterfaceNames(Set<String> networkInterfaceNames) {
155 this.networkInterfaceNames = networkInterfaceNames;
158 public void setServicePorts(Set<Integer> ports) {
159 this.tcpPorts = ports;
162 public void setUseDhcpSniffing(boolean enable) {
163 this.useDHCPsniffing = enable;
166 public void setRefreshInterval(Duration refreshInterval) {
167 this.refreshInterval = refreshInterval;
170 public void setTimeout(Duration timeout) {
171 this.timeout = timeout;
174 public void setPreferResponseTimeAsLatency(boolean preferResponseTimeAsLatency) {
175 this.preferResponseTimeAsLatency = preferResponseTimeAsLatency;
179 * Sets the ping method. This method will perform a feature test. If {@link IpPingMethodEnum#SYSTEM_PING}
180 * does not work on this system, {@link IpPingMethodEnum#JAVA_PING} will be used instead.
182 * @param useSystemPing Set to <code>true</code> to use a system ping method, <code>false</code> to use Java ping
183 * and <code>null</code> to disable ICMP pings.
185 public void setUseIcmpPing(@Nullable Boolean useSystemPing) {
186 if (useSystemPing == null) {
187 ipPingState = "Disabled";
189 } else if (useSystemPing) {
190 final IpPingMethodEnum pingMethod = networkUtils.determinePingMethod();
191 this.pingMethod = pingMethod;
192 ipPingState = pingMethod == IpPingMethodEnum.JAVA_PING ? "System ping feature test failed. Using Java ping"
195 pingMethod = IpPingMethodEnum.JAVA_PING;
196 ipPingState = "Java ping";
201 * Enables or disables ARP pings. Will be automatically disabled if the destination
202 * is not an IPv4 address. If the feature test for the native arping utility fails,
203 * it will be disabled as well.
205 * @param enable Enable or disable ARP ping
206 * @param destinationAddress target ip address
208 private void setUseArpPing(boolean enable, @Nullable InetAddress destinationAddress) {
209 if (!enable || arpPingUtilPath.isEmpty()) {
210 arpPingMethod = ArpPingUtilEnum.DISABLED;
211 } else if (!(destinationAddress instanceof Inet4Address)) {
212 arpPingMethod = ArpPingUtilEnum.DISABLED_INVALID_IP;
217 * Sets the path to ARP ping.
219 * @param enable enable or disable ARP ping
220 * @param arpPingUtilPath enableDHCPListen(useDHCPsniffing);
222 public void setUseArpPing(boolean enable, String arpPingUtilPath, ArpPingUtilEnum arpPingUtilMethod) {
223 setUseArpPing(enable, destination.getValue());
224 this.arpPingUtilPath = arpPingUtilPath;
225 this.arpPingMethod = arpPingUtilMethod;
228 public String getArpPingState() {
229 return arpPingMethod.description;
232 public String getIPPingState() {
236 public String getDhcpState() {
241 * Return <code>true</code> if the device presence detection is performed for an iOS device
242 * like iPhone or iPads. An additional port knock is performed before a ping.
244 public boolean isIOSdevice() {
249 * Set to <code>true</code> if the device presence detection should be performed for an iOS device
250 * like iPhone or iPads. An additional port knock is performed before a ping.
252 public void setIOSDevice(boolean value) {
257 * Return the last seen value as an {@link Instant} or <code>null</code> if not yet seen.
259 public @Nullable Instant getLastSeen() {
264 * Gets the presence detection value synchronously as a {@link PresenceDetectionValue}.
266 * The value is only updated if the cached value has not expired.
268 public PresenceDetectionValue getValue() throws InterruptedException, ExecutionException {
269 return cache.getValue(this::performPresenceDetection).get();
273 * Gets the presence detection value asynchronously as a {@link PresenceDetectionValue}.
275 * The value is only updated if the cached value has not expired.
277 * @param callback a callback with the {@link PresenceDetectionValue}. The callback may
278 * not happen immediately if the cached value expired, but as soon as a new
279 * discovery took place.
281 public void getValue(Consumer<PresenceDetectionValue> callback) {
282 cache.getValue(this::performPresenceDetection).thenAccept(callback);
285 public ExecutorService getThreadsFor(int threadCount) {
286 return Executors.newFixedThreadPool(threadCount);
289 private void withDestinationAddress(Consumer<InetAddress> consumer) {
290 InetAddress destinationAddress = destination.getValue();
291 if (destinationAddress == null) {
292 logger.trace("The destinationAddress for {} is null", hostname);
294 consumer.accept(destinationAddress);
299 * Perform a presence detection with ICMP-, ARP ping and TCP connection attempts simultaneously.
300 * A fixed thread pool will be created with as many threads as necessary to perform all tests at once.
302 * Please be aware of the following restrictions:
304 * <li>ARP pings are only executed on IPv4 addresses.
305 * <li>Non system / Java pings are not recommended at all (not interruptible, useless TCP echo service fall back)
308 * @return a {@link CompletableFuture} for obtaining the {@link PresenceDetectionValue}
310 public CompletableFuture<PresenceDetectionValue> performPresenceDetection() {
311 Set<String> interfaceNames = null;
313 detectionChecks = tcpPorts.size();
314 if (pingMethod != null) {
315 detectionChecks += 1;
317 if (arpPingMethod.canProceed) {
318 if (!lastReachableNetworkInterfaceName.isEmpty()) {
319 interfaceNames = Set.of(lastReachableNetworkInterfaceName);
320 } else if (!networkInterfaceNames.isEmpty()) {
321 interfaceNames = networkInterfaceNames;
323 interfaceNames = networkUtils.getInterfaceNames();
325 detectionChecks += interfaceNames.size();
328 logger.trace("Performing {} presence detection checks for {}", detectionChecks, hostname);
330 PresenceDetectionValue pdv = new PresenceDetectionValue(hostname, PresenceDetectionValue.UNREACHABLE);
332 if (detectionChecks == 0) {
333 return CompletableFuture.completedFuture(pdv);
336 ExecutorService detectionExecutorService = getThreadsFor(detectionChecks);
337 this.detectionExecutorService = detectionExecutorService;
339 List<CompletableFuture<Void>> completableFutures = new ArrayList<>();
341 for (Integer tcpPort : tcpPorts) {
342 completableFutures.add(CompletableFuture.runAsync(() -> {
343 Thread.currentThread().setName("presenceDetectionTCP_" + hostname + " " + tcpPort);
344 performServicePing(pdv, tcpPort);
345 }, detectionExecutorService));
348 // ARP ping for IPv4 addresses. Use single executor for Windows tool and
349 // each own executor for each network interface for other tools
350 if (arpPingMethod == ArpPingUtilEnum.ELI_FULKERSON_ARP_PING_FOR_WINDOWS) {
351 completableFutures.add(CompletableFuture.runAsync(() -> {
352 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " ");
353 // arp-ping.exe tool capable of handling multiple interfaces by itself
354 performArpPing(pdv, "");
355 }, detectionExecutorService));
356 } else if (interfaceNames != null) {
357 for (final String interfaceName : interfaceNames) {
358 completableFutures.add(CompletableFuture.runAsync(() -> {
359 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " " + interfaceName);
360 performArpPing(pdv, interfaceName);
361 }, detectionExecutorService));
366 if (pingMethod != null) {
367 completableFutures.add(CompletableFuture.runAsync(() -> {
368 Thread.currentThread().setName("presenceDetectionICMP_" + hostname);
369 if (pingMethod == IpPingMethodEnum.JAVA_PING) {
370 performJavaPing(pdv);
372 performSystemPing(pdv);
374 }, detectionExecutorService));
377 return CompletableFuture.supplyAsync(() -> {
378 logger.debug("Waiting for {} detection futures for {} to complete", completableFutures.size(), hostname);
379 completableFutures.forEach(CompletableFuture::join);
380 logger.debug("All {} detection futures for {} have completed", completableFutures.size(), hostname);
382 if (!pdv.isReachable()) {
383 logger.debug("{} is unreachable, invalidating destination value", hostname);
384 destination.invalidateValue();
387 logger.debug("Sending listener final result: {}", pdv);
388 updateListener.finalDetectionResult(pdv);
390 detectionExecutorService.shutdownNow();
391 this.detectionExecutorService = null;
395 }, scheduledExecutorService);
399 * Creates a new {@link PresenceDetectionValue} when a host is reachable. Also updates the {@link #lastSeen}
400 * value and sends a partial detection result to the {@link #updateListener}.
402 * It is safe to call this method from multiple threads.
404 * @param type the detection type
405 * @param latency the latency
407 synchronized PresenceDetectionValue updateReachable(PresenceDetectionType type, Duration latency) {
408 PresenceDetectionValue pdv = new PresenceDetectionValue(hostname, latency);
409 updateReachable(pdv, type, latency);
414 * Updates the given {@link PresenceDetectionValue} when a host is reachable. Also updates the {@link #lastSeen}
415 * value and sends a partial detection result to the {@link #updateListener}.
417 * It is safe to call this method from multiple threads.
419 * @param pdv the {@link PresenceDetectionValue} to update
420 * @param type the detection type
421 * @param latency the latency
423 synchronized void updateReachable(PresenceDetectionValue pdv, PresenceDetectionType type, Duration latency) {
424 updateReachable(pdv, type, latency, -1);
427 synchronized void updateReachable(PresenceDetectionValue pdv, PresenceDetectionType type, Duration latency,
429 lastSeen = Instant.now();
430 pdv.addReachableDetectionType(type);
431 pdv.updateLatency(latency);
433 pdv.addReachableTcpPort(tcpPort);
435 logger.debug("Sending listener partial result: {}", pdv);
436 updateListener.partialDetectionResult(pdv);
439 protected void performServicePing(PresenceDetectionValue pdv, int tcpPort) {
440 logger.trace("Perform TCP presence detection for {} on port: {}", hostname, tcpPort);
442 withDestinationAddress(destinationAddress -> {
444 PingResult pingResult = networkUtils.servicePing(destinationAddress.getHostAddress(), tcpPort, timeout);
445 if (pingResult.isSuccess()) {
446 updateReachable(pdv, TCP_CONNECTION, getLatency(pingResult), tcpPort);
448 } catch (IOException e) {
449 // This should not happen and might be a user configuration issue, we log a warning message therefore.
450 logger.warn("Could not create a socket connection", e);
456 * Performs an "ARP ping" (ARP request) on the given interface.
457 * If it is an iOS device, the {@link NetworkUtils#wakeUpIOS(InetAddress)} method is
458 * called before performing the ARP ping.
460 * @param pdv the {@link PresenceDetectionValue} to update
461 * @param interfaceName the interface name. You can request a list of interface names
462 * from {@link NetworkUtils#getInterfaceNames()} for example.
464 protected void performArpPing(PresenceDetectionValue pdv, String interfaceName) {
465 logger.trace("Perform ARP ping presence detection for {} on interface: {}", hostname, interfaceName);
467 withDestinationAddress(destinationAddress -> {
470 networkUtils.wakeUpIOS(destinationAddress);
474 PingResult pingResult = networkUtils.nativeArpPing(arpPingMethod, arpPingUtilPath, interfaceName,
475 destinationAddress.getHostAddress(), timeout);
476 if (pingResult != null) {
477 if (pingResult.isSuccess()) {
478 updateReachable(pdv, ARP_PING, getLatency(pingResult));
479 lastReachableNetworkInterfaceName = interfaceName;
480 } else if (lastReachableNetworkInterfaceName.equals(interfaceName)) {
481 logger.trace("{} is no longer reachable on network interface: {}", hostname, interfaceName);
482 lastReachableNetworkInterfaceName = "";
485 } catch (IOException e) {
486 logger.trace("Failed to execute an ARP ping for {}", hostname, e);
487 } catch (InterruptedException ignored) {
488 // This can be ignored, the thread will end anyway
494 * Performs a Java ping. It is not recommended to use this, as it is not interruptible,
495 * and will not work on Windows systems reliably and will fall back from ICMP pings to
496 * the TCP echo service on port 7 which barely no device or server supports nowadays.
498 * @see InetAddress#isReachable(int)
500 protected void performJavaPing(PresenceDetectionValue pdv) {
501 logger.trace("Perform Java ping presence detection for {}", hostname);
503 withDestinationAddress(destinationAddress -> {
504 PingResult pingResult = networkUtils.javaPing(timeout, destinationAddress);
505 if (pingResult.isSuccess()) {
506 updateReachable(pdv, ICMP_PING, getLatency(pingResult));
511 protected void performSystemPing(PresenceDetectionValue pdv) {
512 logger.trace("Perform native ping presence detection for {}", hostname);
514 withDestinationAddress(destinationAddress -> {
516 PingResult pingResult = networkUtils.nativePing(pingMethod, destinationAddress.getHostAddress(),
518 if (pingResult != null && pingResult.isSuccess()) {
519 updateReachable(pdv, ICMP_PING, getLatency(pingResult));
521 } catch (IOException e) {
522 logger.trace("Failed to execute a native ping for {}", hostname, e);
523 } catch (InterruptedException e) {
524 // This can be ignored, the thread will end anyway
529 private Duration getLatency(PingResult pingResult) {
530 logger.trace("Getting latency from ping result {} using latency mode {}", pingResult,
531 preferResponseTimeAsLatency);
532 Duration executionTime = pingResult.getExecutionTime();
533 Duration responseTime = pingResult.getResponseTime();
534 return preferResponseTimeAsLatency && responseTime != null ? responseTime : executionTime;
538 public void dhcpRequestReceived(String ipAddress) {
539 updateReachable(DHCP_REQUEST, Duration.ZERO);
543 * Start/Restart a fixed scheduled runner to update the devices reach-ability state.
545 public void startAutomaticRefresh() {
546 ScheduledFuture<?> future = refreshJob;
547 if (future != null && !future.isDone()) {
550 refreshJob = scheduledExecutorService.scheduleWithFixedDelay(() -> {
552 logger.debug("Refreshing {} reachability state", hostname);
554 } catch (InterruptedException | ExecutionException e) {
555 logger.debug("Failed to refresh {} presence detection", hostname, e);
557 }, 0, refreshInterval.toMillis(), TimeUnit.MILLISECONDS);
561 * Return <code>true</code> if automatic refreshing is enabled.
563 public boolean isAutomaticRefreshing() {
564 return refreshJob != null;
568 * Stop automatic refreshing.
570 public void stopAutomaticRefresh() {
571 ScheduledFuture<?> future = refreshJob;
572 if (future != null && !future.isDone()) {
576 InetAddress cached = cachedDestination;
577 if (cached != null) {
578 disableDHCPListen(cached);
583 * Enables listening for DHCP packets to figure out if devices have entered the network. This does not work
584 * for iOS devices. The hostname of this network service object will be registered to the DHCP request packet
585 * listener if enabled and unregistered otherwise.
587 * @param destinationAddress the {@link InetAddress} to listen for.
589 private void enableDHCPListen(InetAddress destinationAddress) {
591 DHCPPacketListenerServer listener = DHCPListenService.register(destinationAddress.getHostAddress(), this);
592 dhcpState = String.format("Bound to port %d - %s", listener.getCurrentPort(),
593 (listener.usingPrivilegedPort() ? "Running normally" : "Port forwarding necessary!"));
594 } catch (SocketException e) {
595 dhcpState = String.format("Cannot use DHCP sniffing: %s", e.getMessage());
596 logger.warn("{}", dhcpState);
597 useDHCPsniffing = false;
601 private void disableDHCPListen(InetAddress destinationAddress) {
602 DHCPListenService.unregister(destinationAddress.getHostAddress());