]> git.basschouten.com Git - openhab-addons.git/blob
0877cbb89a97bfa2fed4f08edefa552cb15118ad
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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 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;
21 import java.util.Set;
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;
28
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;
42
43 /**
44  * The {@link PresenceDetection} handles the connection to the Device
45  *
46  * @author Marc Mettke - Initial contribution
47  * @author David Gräff, 2017 - Rewritten
48  * @author Jan N. Klug - refactored host name resolution
49  */
50 @NonNullByDefault
51 public class PresenceDetection implements IPRequestReceivedCallback {
52
53     private static final int DESTINATION_TTL = 300 * 1000; // in ms, 300 s
54
55     NetworkUtils networkUtils = new NetworkUtils();
56     private final Logger logger = LoggerFactory.getLogger(PresenceDetection.class);
57
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<>();
66
67     private long refreshIntervalInMS = 60000;
68     private int timeoutInMS = 5000;
69     private long lastSeenInMS;
70
71     private @NonNullByDefault({}) String hostname;
72     private @NonNullByDefault({}) ExpiringCache<@Nullable InetAddress> destination;
73     private @Nullable InetAddress cachedDestination = null;
74
75     private boolean preferResponseTimeAsLatency;
76
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;
84     int detectionChecks;
85
86     public PresenceDetection(final PresenceDetectionListener updateListener, int cacheDeviceStateTimeInMS)
87             throws IllegalArgumentException {
88         this.updateListener = updateListener;
89         cache = new ExpiringCacheAsync<>(cacheDeviceStateTimeInMS, () -> {
90             performPresenceDetection(false);
91         });
92     }
93
94     public @Nullable String getHostname() {
95         return hostname;
96     }
97
98     public Set<Integer> getServicePorts() {
99         return tcpPorts;
100     }
101
102     public long getRefreshInterval() {
103         return refreshIntervalInMS;
104     }
105
106     public int getTimeout() {
107         return timeoutInMS;
108     }
109
110     public void setHostname(String hostname) {
111         this.hostname = hostname;
112         this.destination = new ExpiringCache<>(DESTINATION_TTL, () -> {
113             try {
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);
122                         }
123                         enableDHCPListen(destinationAddress);
124                     }
125                     cachedDestination = destinationAddress;
126                 }
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;
134                 }
135                 return null;
136             }
137         });
138     }
139
140     public void setServicePorts(Set<Integer> ports) {
141         this.tcpPorts = ports;
142     }
143
144     public void setUseDhcpSniffing(boolean enable) {
145         this.useDHCPsniffing = enable;
146     }
147
148     public void setRefreshInterval(long refreshInterval) {
149         this.refreshIntervalInMS = refreshInterval;
150     }
151
152     public void setTimeout(int timeout) {
153         this.timeoutInMS = timeout;
154     }
155
156     public void setPreferResponseTimeAsLatency(boolean preferResponseTimeAsLatency) {
157         this.preferResponseTimeAsLatency = preferResponseTimeAsLatency;
158     }
159
160     /**
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.
163      *
164      * @param useSystemPing Set to true to use a system ping method, false to use java ping and null to disable ICMP
165      *            pings.
166      */
167     public void setUseIcmpPing(@Nullable Boolean useSystemPing) {
168         if (useSystemPing == null) {
169             ipPingState = "Disabled";
170             pingMethod = null;
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"
175                     : pingMethod.name();
176         } else {
177             pingMethod = IpPingMethodEnum.JAVA_PING;
178             ipPingState = "Java ping";
179         }
180     }
181
182     /**
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.
186      *
187      * @param enable Enable or disable ARP ping
188      * @param destinationAddress target ip address
189      */
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;
195         }
196     }
197
198     /**
199      * sets the path to arp ping
200      *
201      * @param enable Enable or disable ARP ping
202      * @param arpPingUtilPath enableDHCPListen(useDHCPsniffing);
203      */
204     public void setUseArpPing(boolean enable, String arpPingUtilPath, ArpPingUtilEnum arpPingUtilMethod) {
205         setUseArpPing(enable, destination.getValue());
206         this.arpPingUtilPath = arpPingUtilPath;
207         this.arpPingMethod = arpPingUtilMethod;
208     }
209
210     public String getArpPingState() {
211         return arpPingMethod.description;
212     }
213
214     public String getIPPingState() {
215         return ipPingState;
216     }
217
218     public String getDhcpState() {
219         return dhcpState;
220     }
221
222     /**
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.
225      */
226     public boolean isIOSdevice() {
227         return iosDevice;
228     }
229
230     /**
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.
233      */
234     public void setIOSDevice(boolean value) {
235         iosDevice = value;
236     }
237
238     /**
239      * Return the last seen value in milliseconds based on {@link System.currentTimeMillis()} or 0 if not seen yet.
240      */
241     public long getLastSeen() {
242         return lastSeenInMS;
243     }
244
245     /**
246      * Return asynchronously the value of the presence detection as a PresenceDetectionValue.
247      *
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.
251      */
252     public void getValue(Consumer<PresenceDetectionValue> callback) {
253         cache.getValue(callback);
254     }
255
256     public ExecutorService getThreadsFor(int threadCount) {
257         return Executors.newFixedThreadPool(threadCount);
258     }
259
260     /**
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.
264      *
265      * This is a NO-OP, if there is already an ongoing detection or if the cached value
266      * is not expired yet.
267      *
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)
272      *
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.
275      */
276     public boolean performPresenceDetection(boolean waitForDetectionToFinish) {
277         if (executorService != null) {
278             logger.debug(
279                     "There is already an ongoing presence discovery for {} and a new one was issued by the scheduler! TCP Port {}",
280                     hostname, tcpPorts);
281             return false;
282         }
283
284         if (!cache.isExpired()) {
285             return false;
286         }
287
288         Set<String> interfaceNames = null;
289
290         currentCheck = 0;
291         detectionChecks = tcpPorts.size();
292         if (pingMethod != null) {
293             detectionChecks += 1;
294         }
295         if (arpPingMethod.canProceed) {
296             interfaceNames = networkUtils.getInterfaceNames();
297             detectionChecks += interfaceNames.size();
298         }
299
300         if (detectionChecks == 0) {
301             return false;
302         }
303
304         final ExecutorService executorService = getThreadsFor(detectionChecks);
305         this.executorService = executorService;
306
307         for (Integer tcpPort : tcpPorts) {
308             executorService.execute(() -> {
309                 Thread.currentThread().setName("presenceDetectionTCP_" + hostname + " " + String.valueOf(tcpPort));
310                 performServicePing(tcpPort);
311                 checkIfFinished();
312             });
313         }
314
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
321                 performARPping("");
322                 checkIfFinished();
323             });
324         } else if (interfaceNames != null) {
325             for (final String interfaceName : interfaceNames) {
326                 executorService.execute(() -> {
327                     Thread.currentThread().setName("presenceDetectionARP_" + hostname + " " + interfaceName);
328                     performARPping(interfaceName);
329                     checkIfFinished();
330                 });
331             }
332         }
333
334         // ICMP ping
335         if (pingMethod != null) {
336             executorService.execute(() -> {
337                 if (pingMethod != IpPingMethodEnum.JAVA_PING) {
338                     Thread.currentThread().setName("presenceDetectionICMP_" + hostname);
339                     performSystemPing();
340                 } else {
341                     performJavaPing();
342                 }
343                 checkIfFinished();
344             });
345         }
346
347         if (waitForDetectionToFinish) {
348             waitForPresenceDetection();
349         }
350
351         return true;
352     }
353
354     /**
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
358      * shut down.
359      */
360     private synchronized void submitFinalResult() {
361         // Do nothing if we are not in a detection process
362         ExecutorService service = executorService;
363         if (service == null) {
364             return;
365         }
366         // Finish the detection process
367         service.shutdownNow();
368         executorService = null;
369         detectionChecks = 0;
370
371         PresenceDetectionValue v;
372
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);
378         } else {
379             // Make the cache valid again and submit the value.
380             v = cache.getExpiredValue();
381         }
382         cache.setValue(v);
383
384         if (!v.isReachable()) {
385             // if target can't be reached, check if name resolution need to be updated
386             destination.invalidateValue();
387         }
388         updateListener.finalDetectionResult(v);
389     }
390
391     /**
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.
395      * Thread safe.
396      */
397     private synchronized void checkIfFinished() {
398         currentCheck += 1;
399         if (currentCheck < detectionChecks) {
400             return;
401         }
402         submitFinalResult();
403     }
404
405     /**
406      * Waits for the presence detection threads to finish. Returns immediately
407      * if no presence detection is performed right now.
408      */
409     public void waitForPresenceDetection() {
410         ExecutorService service = executorService;
411         if (service == null) {
412             return;
413         }
414         try {
415             // We may get interrupted here by cancelRefreshJob().
416             service.awaitTermination(timeoutInMS + 100, TimeUnit.MILLISECONDS);
417             submitFinalResult();
418         } catch (InterruptedException e) {
419             Thread.currentThread().interrupt(); // Reset interrupt flag
420             service.shutdownNow();
421             executorService = null;
422         }
423     }
424
425     /**
426      * If the cached PresenceDetectionValue has not expired yet, the cached version
427      * is returned otherwise a new reachable PresenceDetectionValue is created with
428      * a latency of 0.
429      *
430      * It is safe to call this method from multiple threads. The returned PresenceDetectionValue
431      * might be still be altered in other threads though.
432      *
433      * @param type The detection type
434      * @return The non expired or a new instance of PresenceDetectionValue.
435      */
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);
441         } else {
442             v = cache.getExpiredValue();
443         }
444         v.updateLatency(latency);
445         v.addType(type);
446         cache.setValue(v);
447         return v;
448     }
449
450     protected void performServicePing(int tcpPort) {
451         logger.trace("Perform TCP presence detection for {} on port: {}", hostname, tcpPort);
452         try {
453             InetAddress destinationAddress = destination.getValue();
454             if (destinationAddress != null) {
455                 networkUtils.servicePing(destinationAddress.getHostAddress(), tcpPort, timeoutInMS).ifPresent(o -> {
456                     if (o.isSuccess()) {
457                         PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.TCP_CONNECTION,
458                                 getLatency(o, preferResponseTimeAsLatency));
459                         v.addReachableTcpService(tcpPort);
460                         updateListener.partialDetectionResult(v);
461                     }
462                 });
463             }
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);
467         }
468     }
469
470     /**
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.
474      *
475      * @param interfaceName The interface name. You can request a list of interface names
476      *            from {@see NetworkUtils.getInterfaceNames()} for example.
477      */
478     protected void performARPping(String interfaceName) {
479         try {
480             logger.trace("Perform ARP ping presence detection for {} on interface: {}", hostname, interfaceName);
481             InetAddress destinationAddress = destination.getValue();
482             if (destinationAddress == null) {
483                 return;
484             }
485             if (iosDevice) {
486                 networkUtils.wakeUpIOS(destinationAddress);
487                 Thread.sleep(50);
488             }
489
490             networkUtils.nativeARPPing(arpPingMethod, arpPingUtilPath, interfaceName,
491                     destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
492                         if (o.isSuccess()) {
493                             PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ARP_PING,
494                                     getLatency(o, preferResponseTimeAsLatency));
495                             updateListener.partialDetectionResult(v);
496                         }
497                     });
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
502         }
503     }
504
505     /**
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      * (http://docs.oracle.com/javase/7/docs/api/java/net/InetAddress.html#isReachable%28int%29)
510      */
511     protected void performJavaPing() {
512         logger.trace("Perform java ping presence detection for {}", hostname);
513
514         InetAddress destinationAddress = destination.getValue();
515         if (destinationAddress == null) {
516             return;
517         }
518
519         networkUtils.javaPing(timeoutInMS, destinationAddress).ifPresent(o -> {
520             if (o.isSuccess()) {
521                 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
522                         getLatency(o, preferResponseTimeAsLatency));
523                 updateListener.partialDetectionResult(v);
524             }
525         });
526     }
527
528     protected void performSystemPing() {
529         try {
530             logger.trace("Perform native ping presence detection for {}", hostname);
531             InetAddress destinationAddress = destination.getValue();
532             if (destinationAddress == null) {
533                 return;
534             }
535
536             networkUtils.nativePing(pingMethod, destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
537                 if (o.isSuccess()) {
538                     PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
539                             getLatency(o, preferResponseTimeAsLatency));
540                     updateListener.partialDetectionResult(v);
541                 }
542             });
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
547         }
548     }
549
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();
555
556         if (preferResponseTimeAsLatency && pingResult.getResponseTimeInMS().isPresent()) {
557             latency = pingResult.getResponseTimeInMS().get();
558         }
559
560         return latency;
561     }
562
563     @Override
564     public void dhcpRequestReceived(String ipAddress) {
565         PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.DHCP_REQUEST, 0);
566         updateListener.partialDetectionResult(v);
567     }
568
569     /**
570      * Start/Restart a fixed scheduled runner to update the devices reach-ability state.
571      *
572      * @param scheduledExecutorService A scheduler to run pings periodically.
573      */
574     public void startAutomaticRefresh(ScheduledExecutorService scheduledExecutorService) {
575         ScheduledFuture<?> future = refreshJob;
576         if (future != null && !future.isDone()) {
577             future.cancel(true);
578         }
579         refreshJob = scheduledExecutorService.scheduleWithFixedDelay(() -> performPresenceDetection(true), 0,
580                 refreshIntervalInMS, TimeUnit.MILLISECONDS);
581     }
582
583     /**
584      * Return true if automatic refreshing is enabled.
585      */
586     public boolean isAutomaticRefreshing() {
587         return refreshJob != null;
588     }
589
590     /**
591      * Stop automatic refreshing.
592      */
593     public void stopAutomaticRefresh() {
594         ScheduledFuture<?> future = refreshJob;
595         if (future != null && !future.isDone()) {
596             future.cancel(true);
597             refreshJob = null;
598         }
599         InetAddress cached = cachedDestination;
600         if (cached != null) {
601             disableDHCPListen(cached);
602         }
603     }
604
605     /**
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.
609      *
610      * @param destinationAddress the InetAddress to listen for.
611      */
612     private void enableDHCPListen(InetAddress destinationAddress) {
613         try {
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;
621         }
622     }
623
624     private void disableDHCPListen(InetAddress destinationAddress) {
625         DHCPListenService.unregister(destinationAddress.getHostAddress());
626         dhcpState = "off";
627     }
628 }