]> git.basschouten.com Git - openhab-addons.git/blob
f6b4cd2b60e758d35a69472f992d6d4bade0e338
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.network.internal;
14
15 import static org.openhab.binding.network.internal.PresenceDetectionType.*;
16
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;
27 import java.util.Set;
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;
36
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;
50
51 /**
52  * The {@link PresenceDetection} handles the connection to the Device.
53  *
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
58  */
59 @NonNullByDefault
60 public class PresenceDetection implements IPRequestReceivedCallback {
61
62     private static final Duration DESTINATION_TTL = Duration.ofMinutes(5);
63
64     NetworkUtils networkUtils = new NetworkUtils();
65     private final Logger logger = LoggerFactory.getLogger(PresenceDetection.class);
66
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<>();
75
76     private Duration refreshInterval = Duration.ofMinutes(1);
77     private Duration timeout = Duration.ofSeconds(5);
78     private @Nullable Instant lastSeen;
79
80     private @NonNullByDefault({}) String hostname;
81     private @NonNullByDefault({}) ExpiringCache<@Nullable InetAddress> destination;
82     private @Nullable InetAddress cachedDestination;
83
84     private boolean preferResponseTimeAsLatency;
85
86     // State variables (cannot be final because of test dependency injections)
87     ExpiringCacheAsync<PresenceDetectionValue> cache;
88
89     private final PresenceDetectionListener updateListener;
90     private ScheduledExecutorService scheduledExecutorService;
91
92     private Set<String> networkInterfaceNames = Set.of();
93     private @Nullable ScheduledFuture<?> refreshJob;
94     protected @Nullable ExecutorService detectionExecutorService;
95     private String dhcpState = "off";
96     int detectionChecks;
97     private String lastReachableNetworkInterfaceName = "";
98
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);
105     }
106
107     public @Nullable String getHostname() {
108         return hostname;
109     }
110
111     public Set<Integer> getServicePorts() {
112         return tcpPorts;
113     }
114
115     public Duration getRefreshInterval() {
116         return refreshInterval;
117     }
118
119     public Duration getTimeout() {
120         return timeout;
121     }
122
123     public void setHostname(String hostname) {
124         this.hostname = hostname;
125         this.destination = new ExpiringCache<>(DESTINATION_TTL, () -> {
126             try {
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,
131                             destinationAddress);
132                     setUseArpPing(true, destinationAddress);
133                     if (useDHCPsniffing) {
134                         if (cached != null) {
135                             disableDHCPListen(cached);
136                         }
137                         enableDHCPListen(destinationAddress);
138                     }
139                     cachedDestination = destinationAddress;
140                 }
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;
148                 }
149                 return null;
150             }
151         });
152     }
153
154     public void setNetworkInterfaceNames(Set<String> networkInterfaceNames) {
155         this.networkInterfaceNames = networkInterfaceNames;
156     }
157
158     public void setServicePorts(Set<Integer> ports) {
159         this.tcpPorts = ports;
160     }
161
162     public void setUseDhcpSniffing(boolean enable) {
163         this.useDHCPsniffing = enable;
164     }
165
166     public void setRefreshInterval(Duration refreshInterval) {
167         this.refreshInterval = refreshInterval;
168     }
169
170     public void setTimeout(Duration timeout) {
171         this.timeout = timeout;
172     }
173
174     public void setPreferResponseTimeAsLatency(boolean preferResponseTimeAsLatency) {
175         this.preferResponseTimeAsLatency = preferResponseTimeAsLatency;
176     }
177
178     /**
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.
181      *
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.
184      */
185     public void setUseIcmpPing(@Nullable Boolean useSystemPing) {
186         if (useSystemPing == null) {
187             ipPingState = "Disabled";
188             pingMethod = null;
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"
193                     : pingMethod.name();
194         } else {
195             pingMethod = IpPingMethodEnum.JAVA_PING;
196             ipPingState = "Java ping";
197         }
198     }
199
200     /**
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.
204      *
205      * @param enable Enable or disable ARP ping
206      * @param destinationAddress target ip address
207      */
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;
213         }
214     }
215
216     /**
217      * Sets the path to ARP ping.
218      *
219      * @param enable enable or disable ARP ping
220      * @param arpPingUtilPath enableDHCPListen(useDHCPsniffing);
221      */
222     public void setUseArpPing(boolean enable, String arpPingUtilPath, ArpPingUtilEnum arpPingUtilMethod) {
223         setUseArpPing(enable, destination.getValue());
224         this.arpPingUtilPath = arpPingUtilPath;
225         this.arpPingMethod = arpPingUtilMethod;
226     }
227
228     public String getArpPingState() {
229         return arpPingMethod.description;
230     }
231
232     public String getIPPingState() {
233         return ipPingState;
234     }
235
236     public String getDhcpState() {
237         return dhcpState;
238     }
239
240     /**
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.
243      */
244     public boolean isIOSdevice() {
245         return iosDevice;
246     }
247
248     /**
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.
251      */
252     public void setIOSDevice(boolean value) {
253         iosDevice = value;
254     }
255
256     /**
257      * Return the last seen value as an {@link Instant} or <code>null</code> if not yet seen.
258      */
259     public @Nullable Instant getLastSeen() {
260         return lastSeen;
261     }
262
263     /**
264      * Gets the presence detection value synchronously as a {@link PresenceDetectionValue}.
265      * <p>
266      * The value is only updated if the cached value has not expired.
267      */
268     public PresenceDetectionValue getValue() throws InterruptedException, ExecutionException {
269         return cache.getValue(this::performPresenceDetection).get();
270     }
271
272     /**
273      * Gets the presence detection value asynchronously as a {@link PresenceDetectionValue}.
274      * <p>
275      * The value is only updated if the cached value has not expired.
276      *
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.
280      */
281     public void getValue(Consumer<PresenceDetectionValue> callback) {
282         cache.getValue(this::performPresenceDetection).thenAccept(callback);
283     }
284
285     public ExecutorService getThreadsFor(int threadCount) {
286         return Executors.newFixedThreadPool(threadCount);
287     }
288
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);
293         } else {
294             consumer.accept(destinationAddress);
295         }
296     }
297
298     /**
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.
301      *
302      * Please be aware of the following restrictions:
303      * <ul>
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)
306      * </ul>
307      *
308      * @return a {@link CompletableFuture} for obtaining the {@link PresenceDetectionValue}
309      */
310     public CompletableFuture<PresenceDetectionValue> performPresenceDetection() {
311         Set<String> interfaceNames = null;
312
313         detectionChecks = tcpPorts.size();
314         if (pingMethod != null) {
315             detectionChecks += 1;
316         }
317         if (arpPingMethod.canProceed) {
318             if (!lastReachableNetworkInterfaceName.isEmpty()) {
319                 interfaceNames = Set.of(lastReachableNetworkInterfaceName);
320             } else if (!networkInterfaceNames.isEmpty()) {
321                 interfaceNames = networkInterfaceNames;
322             } else {
323                 interfaceNames = networkUtils.getInterfaceNames();
324             }
325             detectionChecks += interfaceNames.size();
326         }
327
328         logger.trace("Performing {} presence detection checks for {}", detectionChecks, hostname);
329
330         PresenceDetectionValue pdv = new PresenceDetectionValue(hostname, PresenceDetectionValue.UNREACHABLE);
331
332         if (detectionChecks == 0) {
333             return CompletableFuture.completedFuture(pdv);
334         }
335
336         ExecutorService detectionExecutorService = getThreadsFor(detectionChecks);
337         this.detectionExecutorService = detectionExecutorService;
338
339         List<CompletableFuture<Void>> completableFutures = new ArrayList<>();
340
341         for (Integer tcpPort : tcpPorts) {
342             completableFutures.add(CompletableFuture.runAsync(() -> {
343                 Thread.currentThread().setName("presenceDetectionTCP_" + hostname + " " + tcpPort);
344                 performServicePing(pdv, tcpPort);
345             }, detectionExecutorService));
346         }
347
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));
362             }
363         }
364
365         // ICMP ping
366         if (pingMethod != null) {
367             completableFutures.add(CompletableFuture.runAsync(() -> {
368                 Thread.currentThread().setName("presenceDetectionICMP_" + hostname);
369                 if (pingMethod == IpPingMethodEnum.JAVA_PING) {
370                     performJavaPing(pdv);
371                 } else {
372                     performSystemPing(pdv);
373                 }
374             }, detectionExecutorService));
375         }
376
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);
381
382             if (!pdv.isReachable()) {
383                 logger.debug("{} is unreachable, invalidating destination value", hostname);
384                 destination.invalidateValue();
385             }
386
387             logger.debug("Sending listener final result: {}", pdv);
388             updateListener.finalDetectionResult(pdv);
389
390             detectionExecutorService.shutdownNow();
391             this.detectionExecutorService = null;
392             detectionChecks = 0;
393
394             return pdv;
395         }, scheduledExecutorService);
396     }
397
398     /**
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}.
401      * <p>
402      * It is safe to call this method from multiple threads.
403      *
404      * @param type the detection type
405      * @param latency the latency
406      */
407     synchronized PresenceDetectionValue updateReachable(PresenceDetectionType type, Duration latency) {
408         PresenceDetectionValue pdv = new PresenceDetectionValue(hostname, latency);
409         updateReachable(pdv, type, latency);
410         return pdv;
411     }
412
413     /**
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}.
416      * <p>
417      * It is safe to call this method from multiple threads.
418      *
419      * @param pdv the {@link PresenceDetectionValue} to update
420      * @param type the detection type
421      * @param latency the latency
422      */
423     synchronized void updateReachable(PresenceDetectionValue pdv, PresenceDetectionType type, Duration latency) {
424         updateReachable(pdv, type, latency, -1);
425     }
426
427     synchronized void updateReachable(PresenceDetectionValue pdv, PresenceDetectionType type, Duration latency,
428             int tcpPort) {
429         lastSeen = Instant.now();
430         pdv.addReachableDetectionType(type);
431         pdv.updateLatency(latency);
432         if (0 <= tcpPort) {
433             pdv.addReachableTcpPort(tcpPort);
434         }
435         logger.debug("Sending listener partial result: {}", pdv);
436         updateListener.partialDetectionResult(pdv);
437     }
438
439     protected void performServicePing(PresenceDetectionValue pdv, int tcpPort) {
440         logger.trace("Perform TCP presence detection for {} on port: {}", hostname, tcpPort);
441
442         withDestinationAddress(destinationAddress -> {
443             try {
444                 PingResult pingResult = networkUtils.servicePing(destinationAddress.getHostAddress(), tcpPort, timeout);
445                 if (pingResult.isSuccess()) {
446                     updateReachable(pdv, TCP_CONNECTION, getLatency(pingResult), tcpPort);
447                 }
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);
451             }
452         });
453     }
454
455     /**
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.
459      *
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.
463      */
464     protected void performArpPing(PresenceDetectionValue pdv, String interfaceName) {
465         logger.trace("Perform ARP ping presence detection for {} on interface: {}", hostname, interfaceName);
466
467         withDestinationAddress(destinationAddress -> {
468             try {
469                 if (iosDevice) {
470                     networkUtils.wakeUpIOS(destinationAddress);
471                     Thread.sleep(50);
472                 }
473
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 = "";
483                     }
484                 }
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
489             }
490         });
491     }
492
493     /**
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.
497      *
498      * @see InetAddress#isReachable(int)
499      */
500     protected void performJavaPing(PresenceDetectionValue pdv) {
501         logger.trace("Perform Java ping presence detection for {}", hostname);
502
503         withDestinationAddress(destinationAddress -> {
504             PingResult pingResult = networkUtils.javaPing(timeout, destinationAddress);
505             if (pingResult.isSuccess()) {
506                 updateReachable(pdv, ICMP_PING, getLatency(pingResult));
507             }
508         });
509     }
510
511     protected void performSystemPing(PresenceDetectionValue pdv) {
512         logger.trace("Perform native ping presence detection for {}", hostname);
513
514         withDestinationAddress(destinationAddress -> {
515             try {
516                 PingResult pingResult = networkUtils.nativePing(pingMethod, destinationAddress.getHostAddress(),
517                         timeout);
518                 if (pingResult != null && pingResult.isSuccess()) {
519                     updateReachable(pdv, ICMP_PING, getLatency(pingResult));
520                 }
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
525             }
526         });
527     }
528
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;
535     }
536
537     @Override
538     public void dhcpRequestReceived(String ipAddress) {
539         updateReachable(DHCP_REQUEST, Duration.ZERO);
540     }
541
542     /**
543      * Start/Restart a fixed scheduled runner to update the devices reach-ability state.
544      */
545     public void startAutomaticRefresh() {
546         ScheduledFuture<?> future = refreshJob;
547         if (future != null && !future.isDone()) {
548             future.cancel(true);
549         }
550         refreshJob = scheduledExecutorService.scheduleWithFixedDelay(() -> {
551             try {
552                 logger.debug("Refreshing {} reachability state", hostname);
553                 getValue();
554             } catch (InterruptedException | ExecutionException e) {
555                 logger.debug("Failed to refresh {} presence detection", hostname, e);
556             }
557         }, 0, refreshInterval.toMillis(), TimeUnit.MILLISECONDS);
558     }
559
560     /**
561      * Return <code>true</code> if automatic refreshing is enabled.
562      */
563     public boolean isAutomaticRefreshing() {
564         return refreshJob != null;
565     }
566
567     /**
568      * Stop automatic refreshing.
569      */
570     public void stopAutomaticRefresh() {
571         ScheduledFuture<?> future = refreshJob;
572         if (future != null && !future.isDone()) {
573             future.cancel(true);
574             refreshJob = null;
575         }
576         InetAddress cached = cachedDestination;
577         if (cached != null) {
578             disableDHCPListen(cached);
579         }
580     }
581
582     /**
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.
586      *
587      * @param destinationAddress the {@link InetAddress} to listen for.
588      */
589     private void enableDHCPListen(InetAddress destinationAddress) {
590         try {
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;
598         }
599     }
600
601     private void disableDHCPListen(InetAddress destinationAddress) {
602         DHCPListenService.unregister(destinationAddress.getHostAddress());
603         dhcpState = "off";
604     }
605 }