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 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;
81 private Set<String> networkInterfaceNames = Set.of();
82 private @Nullable ScheduledFuture<?> refreshJob;
83 protected @Nullable ExecutorService executorService;
84 private String dhcpState = "off";
85 private Integer currentCheck = 0;
87 private String lastReachableNetworkInterfaceName = "";
89 public PresenceDetection(final PresenceDetectionListener updateListener, int cacheDeviceStateTimeInMS)
90 throws IllegalArgumentException {
91 this.updateListener = updateListener;
92 cache = new ExpiringCacheAsync<>(cacheDeviceStateTimeInMS, () -> 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 InetAddress cached = cachedDestination;
117 if (!destinationAddress.equals(cached)) {
118 logger.trace("host name resolved to other address, (re-)setup presence detection");
119 setUseArpPing(true, destinationAddress);
120 if (useDHCPsniffing) {
121 if (cached != null) {
122 disableDHCPListen(cached);
124 enableDHCPListen(destinationAddress);
126 cachedDestination = destinationAddress;
128 return destinationAddress;
129 } catch (UnknownHostException e) {
130 logger.trace("hostname resolution failed");
131 InetAddress cached = cachedDestination;
132 if (cached != null) {
133 disableDHCPListen(cached);
134 cachedDestination = null;
141 public void setNetworkInterfaceNames(Set<String> networkInterfaceNames) {
142 this.networkInterfaceNames = networkInterfaceNames;
145 public void setServicePorts(Set<Integer> ports) {
146 this.tcpPorts = ports;
149 public void setUseDhcpSniffing(boolean enable) {
150 this.useDHCPsniffing = enable;
153 public void setRefreshInterval(long refreshInterval) {
154 this.refreshIntervalInMS = refreshInterval;
157 public void setTimeout(int timeout) {
158 this.timeoutInMS = timeout;
161 public void setPreferResponseTimeAsLatency(boolean preferResponseTimeAsLatency) {
162 this.preferResponseTimeAsLatency = preferResponseTimeAsLatency;
166 * Sets the ping method. This method will perform a feature test. If SYSTEM_PING
167 * does not work on this system, JAVA_PING will be used instead.
169 * @param useSystemPing Set to true to use a system ping method, false to use java ping and null to disable ICMP
172 public void setUseIcmpPing(@Nullable Boolean useSystemPing) {
173 if (useSystemPing == null) {
174 ipPingState = "Disabled";
176 } else if (useSystemPing) {
177 final IpPingMethodEnum pingMethod = networkUtils.determinePingMethod();
178 this.pingMethod = pingMethod;
179 ipPingState = pingMethod == IpPingMethodEnum.JAVA_PING ? "System ping feature test failed. Using Java ping"
182 pingMethod = IpPingMethodEnum.JAVA_PING;
183 ipPingState = "Java ping";
188 * Enables or disables ARP pings. Will be automatically disabled if the destination
189 * is not an IPv4 address. If the feature test for the native arping utility fails,
190 * it will be disabled as well.
192 * @param enable Enable or disable ARP ping
193 * @param destinationAddress target ip address
195 private void setUseArpPing(boolean enable, @Nullable InetAddress destinationAddress) {
196 if (!enable || arpPingUtilPath.isEmpty()) {
197 arpPingMethod = ArpPingUtilEnum.DISABLED;
198 } else if (!(destinationAddress instanceof Inet4Address)) {
199 arpPingMethod = ArpPingUtilEnum.DISABLED_INVALID_IP;
204 * sets the path to arp ping
206 * @param enable Enable or disable ARP ping
207 * @param arpPingUtilPath enableDHCPListen(useDHCPsniffing);
209 public void setUseArpPing(boolean enable, String arpPingUtilPath, ArpPingUtilEnum arpPingUtilMethod) {
210 setUseArpPing(enable, destination.getValue());
211 this.arpPingUtilPath = arpPingUtilPath;
212 this.arpPingMethod = arpPingUtilMethod;
215 public String getArpPingState() {
216 return arpPingMethod.description;
219 public String getIPPingState() {
223 public String getDhcpState() {
228 * Return true if the device presence detection is performed for an iOS device
229 * like iPhone or iPads. An additional port knock is performed before a ping.
231 public boolean isIOSdevice() {
236 * Set to true if the device presence detection should be performed for an iOS device
237 * like iPhone or iPads. An additional port knock is performed before a ping.
239 public void setIOSDevice(boolean value) {
244 * Return the last seen value in milliseconds based on {@link System#currentTimeMillis()} or 0 if not seen yet.
246 public long getLastSeen() {
251 * Return asynchronously the value of the presence detection as a PresenceDetectionValue.
253 * @param callback A callback with the PresenceDetectionValue. The callback may
254 * not happen immediately if the cached value expired, but as soon as a new
255 * discovery took place.
257 public void getValue(Consumer<PresenceDetectionValue> callback) {
258 cache.getValue(callback);
261 public ExecutorService getThreadsFor(int threadCount) {
262 return Executors.newFixedThreadPool(threadCount);
266 * Perform a presence detection with ICMP-, ARP ping and
267 * TCP connection attempts simultaneously. A fixed thread pool will be created with as many
268 * thread as necessary to perform all tests at once.
270 * This is a NO-OP, if there is already an ongoing detection or if the cached value
271 * is not expired yet.
273 * Please be aware of the following restrictions:
274 * - ARP pings are only executed on IPv4 addresses.
275 * - Non system / Java pings are not recommended at all
276 * (not interruptible, useless TCP echo service fall back)
278 * @param waitForDetectionToFinish If you want to synchronously wait for the result, set this to true
279 * @return Return true if a presence detection is performed and false otherwise.
281 public boolean performPresenceDetection(boolean waitForDetectionToFinish) {
282 if (executorService != null) {
284 "There is already an ongoing presence discovery for {} and a new one was issued by the scheduler! TCP Port {}",
289 if (!cache.isExpired()) {
293 Set<String> interfaceNames = null;
296 detectionChecks = tcpPorts.size();
297 if (pingMethod != null) {
298 detectionChecks += 1;
300 if (arpPingMethod.canProceed) {
301 if (!lastReachableNetworkInterfaceName.isEmpty()) {
302 interfaceNames = Set.of(lastReachableNetworkInterfaceName);
303 } else if (!networkInterfaceNames.isEmpty()) {
304 interfaceNames = networkInterfaceNames;
306 interfaceNames = networkUtils.getInterfaceNames();
308 detectionChecks += interfaceNames.size();
311 if (detectionChecks == 0) {
315 final ExecutorService executorService = getThreadsFor(detectionChecks);
316 this.executorService = executorService;
318 for (Integer tcpPort : tcpPorts) {
319 executorService.execute(() -> {
320 Thread.currentThread().setName("presenceDetectionTCP_" + hostname + " " + tcpPort);
321 performServicePing(tcpPort);
326 // ARP ping for IPv4 addresses. Use single executor for Windows tool and
327 // each own executor for each network interface for other tools
328 if (arpPingMethod == ArpPingUtilEnum.ELI_FULKERSON_ARP_PING_FOR_WINDOWS) {
329 executorService.execute(() -> {
330 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " ");
331 // arp-ping.exe tool capable of handling multiple interfaces by itself
335 } else if (interfaceNames != null) {
336 for (final String interfaceName : interfaceNames) {
337 executorService.execute(() -> {
338 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " " + interfaceName);
339 performARPping(interfaceName);
346 if (pingMethod != null) {
347 executorService.execute(() -> {
348 if (pingMethod != IpPingMethodEnum.JAVA_PING) {
349 Thread.currentThread().setName("presenceDetectionICMP_" + hostname);
358 if (waitForDetectionToFinish) {
359 waitForPresenceDetection();
366 * Calls updateListener.finalDetectionResult() with a final result value.
367 * Safe to be called from different threads. After a call to this method,
368 * the presence detection process is finished and all threads are forcefully
371 private synchronized void submitFinalResult() {
372 // Do nothing if we are not in a detection process
373 ExecutorService service = executorService;
374 if (service == null) {
377 // Finish the detection process
378 service.shutdownNow();
379 executorService = null;
382 PresenceDetectionValue v;
384 // The cache will be expired by now if cache_time < timeoutInMS. But the device might be actually reachable.
385 // Therefore use lastSeenInMS here and not cache.isExpired() to determine if we got a ping response.
386 if (lastSeenInMS + timeoutInMS + 100 < System.currentTimeMillis()) {
387 // We haven't seen the device in the detection process
388 v = new PresenceDetectionValue(hostname, -1);
390 // Make the cache valid again and submit the value.
391 v = cache.getExpiredValue();
395 if (!v.isReachable()) {
396 // if target can't be reached, check if name resolution need to be updated
397 destination.invalidateValue();
399 updateListener.finalDetectionResult(v);
403 * This method is called after each individual check and increases a check counter.
404 * If the counter equals the total checks,the final result is submitted. This will
405 * happen way before the "timeoutInMS", if all checks were successful.
408 private synchronized void checkIfFinished() {
410 if (currentCheck < detectionChecks) {
417 * Waits for the presence detection threads to finish. Returns immediately
418 * if no presence detection is performed right now.
420 public void waitForPresenceDetection() {
421 ExecutorService service = executorService;
422 if (service == null) {
426 // We may get interrupted here by cancelRefreshJob().
427 service.awaitTermination(timeoutInMS + 100, TimeUnit.MILLISECONDS);
429 } catch (InterruptedException e) {
430 Thread.currentThread().interrupt(); // Reset interrupt flag
431 service.shutdownNow();
432 executorService = null;
437 * If the cached PresenceDetectionValue has not expired yet, the cached version
438 * is returned otherwise a new reachable PresenceDetectionValue is created with
441 * It is safe to call this method from multiple threads. The returned PresenceDetectionValue
442 * might be still be altered in other threads though.
444 * @param type The detection type
445 * @return The non expired or a new instance of PresenceDetectionValue.
447 synchronized PresenceDetectionValue updateReachableValue(PresenceDetectionType type, double latency) {
448 lastSeenInMS = System.currentTimeMillis();
449 PresenceDetectionValue v;
450 if (cache.isExpired()) {
451 v = new PresenceDetectionValue(hostname, 0);
453 v = cache.getExpiredValue();
455 v.updateLatency(latency);
461 protected void performServicePing(int tcpPort) {
462 logger.trace("Perform TCP presence detection for {} on port: {}", hostname, tcpPort);
464 InetAddress destinationAddress = destination.getValue();
465 if (destinationAddress != null) {
466 networkUtils.servicePing(destinationAddress.getHostAddress(), tcpPort, timeoutInMS).ifPresent(o -> {
468 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.TCP_CONNECTION,
469 getLatency(o, preferResponseTimeAsLatency));
470 v.addReachableTcpService(tcpPort);
471 updateListener.partialDetectionResult(v);
475 } catch (IOException e) {
476 // This should not happen and might be a user configuration issue, we log a warning message therefore.
477 logger.warn("Could not create a socket connection", e);
482 * Performs an "ARP ping" (ARP request) on the given interface.
483 * If it is an iOS device, the {@see NetworkUtils.wakeUpIOS()} method is
484 * called before performing the ARP ping.
486 * @param interfaceName The interface name. You can request a list of interface names
487 * from {@see NetworkUtils.getInterfaceNames()} for example.
489 protected void performARPping(String interfaceName) {
491 logger.trace("Perform ARP ping presence detection for {} on interface: {}", hostname, interfaceName);
492 InetAddress destinationAddress = destination.getValue();
493 if (destinationAddress == null) {
497 networkUtils.wakeUpIOS(destinationAddress);
501 networkUtils.nativeARPPing(arpPingMethod, arpPingUtilPath, interfaceName,
502 destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
504 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ARP_PING,
505 getLatency(o, preferResponseTimeAsLatency));
506 updateListener.partialDetectionResult(v);
507 lastReachableNetworkInterfaceName = interfaceName;
508 } else if (lastReachableNetworkInterfaceName.equals(interfaceName)) {
509 logger.trace("{} is no longer reachable on network interface: {}", hostname, interfaceName);
510 lastReachableNetworkInterfaceName = "";
513 } catch (IOException e) {
514 logger.trace("Failed to execute an arp ping for ip {}", hostname, e);
515 } catch (InterruptedException ignored) {
516 // This can be ignored, the thread will end anyway
521 * Performs a java ping. It is not recommended to use this, as it is not interruptible,
522 * and will not work on windows systems reliably and will fall back from ICMP pings to
523 * the TCP echo service on port 7 which barely no device or server supports nowadays.
524 * (https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/InetAddress.html#isReachable%28int%29)
526 protected void performJavaPing() {
527 logger.trace("Perform java ping presence detection for {}", hostname);
529 InetAddress destinationAddress = destination.getValue();
530 if (destinationAddress == null) {
534 networkUtils.javaPing(timeoutInMS, destinationAddress).ifPresent(o -> {
536 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
537 getLatency(o, preferResponseTimeAsLatency));
538 updateListener.partialDetectionResult(v);
543 protected void performSystemPing() {
545 logger.trace("Perform native ping presence detection for {}", hostname);
546 InetAddress destinationAddress = destination.getValue();
547 if (destinationAddress == null) {
551 networkUtils.nativePing(pingMethod, destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
553 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
554 getLatency(o, preferResponseTimeAsLatency));
555 updateListener.partialDetectionResult(v);
558 } catch (IOException e) {
559 logger.trace("Failed to execute a native ping for ip {}", hostname, e);
560 } catch (InterruptedException e) {
561 // This can be ignored, the thread will end anyway
565 private double getLatency(PingResult pingResult, boolean preferResponseTimeAsLatency) {
566 logger.debug("Getting latency from ping result {} using latency mode {}", pingResult,
567 preferResponseTimeAsLatency);
568 // Execution time is always set and this value is also the default. So lets use it first.
569 double latency = pingResult.getExecutionTimeInMS();
571 if (preferResponseTimeAsLatency && pingResult.getResponseTimeInMS().isPresent()) {
572 latency = pingResult.getResponseTimeInMS().get();
579 public void dhcpRequestReceived(String ipAddress) {
580 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.DHCP_REQUEST, 0);
581 updateListener.partialDetectionResult(v);
585 * Start/Restart a fixed scheduled runner to update the devices reach-ability state.
587 * @param scheduledExecutorService A scheduler to run pings periodically.
589 public void startAutomaticRefresh(ScheduledExecutorService scheduledExecutorService) {
590 ScheduledFuture<?> future = refreshJob;
591 if (future != null && !future.isDone()) {
594 refreshJob = scheduledExecutorService.scheduleWithFixedDelay(() -> performPresenceDetection(true), 0,
595 refreshIntervalInMS, TimeUnit.MILLISECONDS);
599 * Return true if automatic refreshing is enabled.
601 public boolean isAutomaticRefreshing() {
602 return refreshJob != null;
606 * Stop automatic refreshing.
608 public void stopAutomaticRefresh() {
609 ScheduledFuture<?> future = refreshJob;
610 if (future != null && !future.isDone()) {
614 InetAddress cached = cachedDestination;
615 if (cached != null) {
616 disableDHCPListen(cached);
621 * Enables listing for dhcp packets to figure out if devices have entered the network. This does not work
622 * for iOS devices. The hostname of this network service object will be registered to the dhcp request packet
623 * listener if enabled and unregistered otherwise.
625 * @param destinationAddress the InetAddress to listen for.
627 private void enableDHCPListen(InetAddress destinationAddress) {
629 DHCPPacketListenerServer listener = DHCPListenService.register(destinationAddress.getHostAddress(), this);
630 dhcpState = String.format("Bound to port %d - %s", listener.getCurrentPort(),
631 (listener.usingPrivilegedPort() ? "Running normally" : "Port forwarding necessary !"));
632 } catch (SocketException e) {
633 dhcpState = String.format("Cannot use DHCP sniffing: %s", e.getMessage());
634 logger.warn("{}", dhcpState);
635 useDHCPsniffing = false;
639 private void disableDHCPListen(InetAddress destinationAddress) {
640 DHCPListenService.unregister(destinationAddress.getHostAddress());