]> git.basschouten.com Git - openhab-addons.git/blob
f67cdb2d84a203d7658c68252ea5cb285eaa72b2
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.io.neeo.internal.discovery;
14
15 import java.io.File;
16 import java.io.IOException;
17 import java.net.InetAddress;
18 import java.net.UnknownHostException;
19 import java.nio.charset.StandardCharsets;
20 import java.nio.file.Files;
21 import java.util.AbstractMap;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Map.Entry;
26 import java.util.Objects;
27 import java.util.Optional;
28 import java.util.concurrent.ScheduledExecutorService;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.locks.Lock;
31 import java.util.concurrent.locks.ReentrantLock;
32 import java.util.stream.Collectors;
33
34 import javax.jmdns.ServiceEvent;
35 import javax.jmdns.ServiceInfo;
36 import javax.jmdns.ServiceListener;
37
38 import org.apache.commons.lang.StringUtils;
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.openhab.core.common.ThreadPoolManager;
42 import org.openhab.core.io.transport.mdns.MDNSClient;
43 import org.openhab.io.neeo.internal.NeeoApi;
44 import org.openhab.io.neeo.internal.NeeoConstants;
45 import org.openhab.io.neeo.internal.NeeoUtil;
46 import org.openhab.io.neeo.internal.ServiceContext;
47 import org.openhab.io.neeo.internal.models.NeeoSystemInfo;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50
51 import com.google.gson.Gson;
52 import com.google.gson.JsonParseException;
53
54 /**
55  * An implementations of {@link BrainDiscovery} that will discovery brains from the MDNS/Zeroconf/Bonjour service
56  * announcements
57  *
58  * @author Tim Roberts - Initial Contribution
59  */
60 @NonNullByDefault
61 public class MdnsBrainDiscovery extends AbstractBrainDiscovery {
62
63     /** The logger */
64     private final Logger logger = LoggerFactory.getLogger(MdnsBrainDiscovery.class);
65
66     /** The lock that controls access to the {@link #systems} set */
67     private final Lock systemsLock = new ReentrantLock();
68
69     /** The set of {@link NeeoSystemInfo} that has been discovered */
70     private final Map<NeeoSystemInfo, InetAddress> systems = new HashMap<>();
71
72     /** The MDNS listener used. */
73     private final ServiceListener mdnsListener = new ServiceListener() {
74
75         @Override
76         public void serviceAdded(@Nullable ServiceEvent event) {
77             if (event != null) {
78                 considerService(event.getInfo());
79             }
80         }
81
82         @Override
83         public void serviceRemoved(@Nullable ServiceEvent event) {
84             if (event != null) {
85                 removeService(event.getInfo());
86             }
87         }
88
89         @Override
90         public void serviceResolved(@Nullable ServiceEvent event) {
91             if (event != null) {
92                 considerService(event.getInfo());
93             }
94         }
95     };
96
97     /** The service context */
98     private final ServiceContext context;
99
100     /** The scheduler used to schedule tasks */
101     private final ScheduledExecutorService scheduler = ThreadPoolManager
102             .getScheduledPool(NeeoConstants.THREAD_POOL_NAME);
103
104     private final Gson gson = new Gson();
105
106     /** The file we store definitions in */
107     private final File file = new File(NeeoConstants.FILENAME_DISCOVEREDBRAINS);
108
109     /**
110      * Creates the MDNS brain discovery from the given {@link ServiceContext}
111      *
112      * @param context the non-null service context
113      */
114     public MdnsBrainDiscovery(ServiceContext context) {
115         Objects.requireNonNull(context, "context cannot be null");
116         this.context = context;
117     }
118
119     /**
120      * Starts discovery by
121      * <ol>
122      * <li>Listening to future service announcements from the {@link MDNSClient}</li>
123      * <li>Getting a list of all current announcements</li>
124      * </ol>
125      *
126      */
127     @Override
128     public void startDiscovery() {
129         logger.debug("Starting NEEO Brain MDNS Listener");
130         context.getMdnsClient().addServiceListener(NeeoConstants.NEEO_MDNS_TYPE, mdnsListener);
131
132         scheduler.execute(() -> {
133             if (file.exists()) {
134                 try {
135                     logger.debug("Reading contents of {}", file.getAbsolutePath());
136                     final byte[] contents = Files.readAllBytes(file.toPath());
137                     final String json = new String(contents, StandardCharsets.UTF_8);
138                     final String[] ipAddresses = gson.fromJson(json, String[].class);
139                     if (ipAddresses != null) {
140                         logger.debug("Restoring discovery from {}: {}", file.getAbsolutePath(),
141                                 StringUtils.join(ipAddresses, ','));
142                         for (String ipAddress : ipAddresses) {
143                             if (StringUtils.isNotBlank(ipAddress)) {
144                                 addDiscovered(ipAddress, false);
145                             }
146                         }
147                     }
148                 } catch (JsonParseException | UnsupportedOperationException e) {
149                     logger.debug("JsonParseException reading {}: {}", file.toPath(), e.getMessage(), e);
150                 } catch (IOException e) {
151                     logger.debug("IOException reading {}: {}", file.toPath(), e.getMessage(), e);
152                 }
153             }
154
155             for (ServiceInfo info : context.getMdnsClient().list(NeeoConstants.NEEO_MDNS_TYPE)) {
156                 considerService(info);
157             }
158         });
159     }
160
161     @Override
162     public void addListener(DiscoveryListener listener) {
163         super.addListener(listener);
164         systemsLock.lock();
165         try {
166             for (Entry<NeeoSystemInfo, InetAddress> entry : systems.entrySet()) {
167                 listener.discovered(entry.getKey(), entry.getValue());
168             }
169         } finally {
170             systemsLock.unlock();
171         }
172     }
173
174     /**
175      * Return the brain ID and {@link InetAddress} from the {@link ServiceInfo}
176      *
177      * @param info the non-null {@link ServiceInfo}
178      * @return an {@link Entry} that represents the brain ID and the associated IP address
179      */
180     @Nullable
181     private Entry<String, InetAddress> getNeeoBrainInfo(ServiceInfo info) {
182         Objects.requireNonNull(info, "info cannot be null");
183         if (!StringUtils.equals("neeo", info.getApplication())) {
184             logger.debug("A non-neeo application was found for the NEEO MDNS: {}", info);
185             return null;
186         }
187
188         final InetAddress ipAddress = getIpAddress(info);
189         if (ipAddress == null) {
190             logger.debug("Got a NEEO lookup without an IP address (scheduling a list): {}", info);
191             return null;
192         }
193
194         String model = info.getPropertyString("hon"); // model
195         if (model == null) {
196             final String server = info.getServer(); // NEEO-xxxxx.local.
197             if (server != null) {
198                 final int idx = server.indexOf(".");
199                 if (idx >= 0) {
200                     model = server.substring(0, idx);
201                 }
202             }
203         }
204         if (model == null || model.length() <= 5 || !model.toLowerCase().startsWith("neeo")) {
205             logger.debug("No HON or server found to retrieve the model # from: {}", info);
206             return null;
207         }
208
209         return new AbstractMap.SimpleImmutableEntry<>(model, ipAddress);
210     }
211
212     /**
213      * Consider whether the {@link ServiceInfo} is for a NEEO brain. This method simply calls
214      * {@link #considerService(ServiceInfo, int)} with the first attempt (attempts=1).
215      *
216      * @param info the non-null {@link ServiceInfo}
217      */
218     private void considerService(ServiceInfo info) {
219         considerService(info, 1);
220     }
221
222     /**
223      * Consider whether the {@link ServiceInfo} is for a NEEO brain. We first get the info via
224      * {@link #getNeeoBrainInfo(ServiceInfo)} and then attempt to connect to it to retrieve the {@link NeeoSystemInfo}.
225      * If successful and the brain has not been already discovered, a
226      * {@link #fireDiscovered(NeeoSystemInfo, InetAddress)} is issued.
227      *
228      * @param info the non-null {@link ServiceInfo}
229      * @param attempts the number of attempts that have been made
230      */
231     private void considerService(ServiceInfo info, int attempts) {
232         Objects.requireNonNull(info, "info cannot be null");
233         if (attempts < 1) {
234             throw new IllegalArgumentException("attempts cannot be below 1: " + attempts);
235         }
236
237         final Entry<String, InetAddress> brainInfo = getNeeoBrainInfo(info);
238         if (brainInfo == null) {
239             logger.debug("BrainInfo null (ignoring): {}", info);
240             return;
241         }
242
243         logger.debug("NEEO Brain Found: {} (attempt #{} to get information)", brainInfo.getKey(), attempts);
244
245         if (attempts > 120) {
246             logger.debug("NEEO Brain found but couldn't retrieve the system information for {} at {} - giving up!",
247                     brainInfo.getKey(), brainInfo.getValue());
248             return;
249         }
250
251         NeeoSystemInfo sysInfo;
252         try {
253             sysInfo = NeeoApi.getSystemInfo(brainInfo.getValue().toString());
254         } catch (IOException e) {
255             // We can get an MDNS notification BEFORE the brain is ready to process.
256             // if that happens, we'll get an IOException (usually bad gateway message), schedule another attempt to get
257             // the info (rather than lose the notification)
258             scheduler.schedule(() -> {
259                 considerService(info, attempts + 1);
260             }, 1, TimeUnit.SECONDS);
261             return;
262         }
263
264         systemsLock.lock();
265         try {
266             final InetAddress oldAddr = systems.get(sysInfo);
267             final InetAddress newAddr = brainInfo.getValue();
268             if (oldAddr == null) {
269                 systems.put(sysInfo, newAddr);
270                 fireDiscovered(sysInfo, newAddr);
271                 save();
272             } else if (!oldAddr.equals(newAddr)) {
273                 fireRemoved(sysInfo);
274                 systems.put(sysInfo, newAddr);
275                 fireUpdated(sysInfo, oldAddr, newAddr);
276                 save();
277             } else {
278                 logger.debug("NEEO Brain {} already registered", brainInfo.getValue());
279             }
280         } finally {
281             systemsLock.unlock();
282         }
283     }
284
285     @Override
286     public boolean addDiscovered(String ipAddress) {
287         return addDiscovered(ipAddress, true);
288     }
289
290     /**
291      * Adds a discovered IP address and optionally saving it to the brain's discovered file
292      *
293      * @param ipAddress a non-null, non-empty IP address
294      * @param save true to save changes, false otherwise
295      * @return true if discovered, false otherwise
296      */
297     private boolean addDiscovered(String ipAddress, boolean save) {
298         NeeoUtil.requireNotEmpty(ipAddress, "ipAddress cannot be empty");
299
300         try {
301             final InetAddress addr = InetAddress.getByName(ipAddress);
302             final NeeoSystemInfo sysInfo = NeeoApi.getSystemInfo(ipAddress);
303             logger.debug("Manually adding brain ({}) with system information: {}", ipAddress, sysInfo);
304
305             systemsLock.lock();
306             try {
307                 final InetAddress oldAddr = systems.get(sysInfo);
308
309                 systems.put(sysInfo, addr);
310
311                 if (oldAddr == null) {
312                     fireDiscovered(sysInfo, addr);
313                 } else {
314                     fireUpdated(sysInfo, oldAddr, addr);
315                 }
316                 if (save) {
317                     save();
318                 }
319             } finally {
320                 systemsLock.unlock();
321             }
322
323             return true;
324         } catch (IOException e) {
325             logger.debug("Tried to manually add a brain ({}) but an exception occurred: {}", ipAddress, e.getMessage(),
326                     e);
327             return false;
328         }
329     }
330
331     @Override
332     public boolean removeDiscovered(String servletUrl) {
333         NeeoUtil.requireNotEmpty(servletUrl, "servletUrl cannot be null");
334         systemsLock.lock();
335         try {
336             final Optional<NeeoSystemInfo> sysInfo = systems.keySet().stream()
337                     .filter(e -> StringUtils.equals(servletUrl, NeeoUtil.getServletUrl(e.getHostname()))).findFirst();
338             if (sysInfo.isPresent()) {
339                 systems.remove(sysInfo.get());
340                 fireRemoved(sysInfo.get());
341                 save();
342                 return true;
343             } else {
344                 logger.debug("Tried to remove a servlet for {} but none were found - ignored.", servletUrl);
345                 return false;
346             }
347         } finally {
348             systemsLock.unlock();
349         }
350     }
351
352     /**
353      * Removes the service. If the info represents a brain we already discovered, a {@link #fireRemoved(NeeoSystemInfo)}
354      * is issued.
355      *
356      * @param info the non-null {@link ServiceInfo}
357      */
358     private void removeService(ServiceInfo info) {
359         Objects.requireNonNull(info, "info cannot be null");
360
361         final Entry<String, InetAddress> brainInfo = getNeeoBrainInfo(info);
362         if (brainInfo == null) {
363             return;
364         }
365
366         systemsLock.lock();
367         try {
368             NeeoSystemInfo foundInfo = null;
369             for (NeeoSystemInfo existingSysInfo : systems.keySet()) {
370                 if (StringUtils.equals(existingSysInfo.getHostname(), brainInfo.getKey())) {
371                     foundInfo = existingSysInfo;
372                     break;
373                 }
374             }
375             if (foundInfo != null) {
376                 fireRemoved(foundInfo);
377                 systems.remove(foundInfo);
378                 save();
379             }
380         } finally {
381             systemsLock.unlock();
382         }
383     }
384
385     /**
386      * Saves the current brains to the {@link #file}. Any {@link IOException} will be logged and ignored. Please note
387      * that this method ASSUMES that it is called under a lock on {@link #systemsLock}
388      */
389     private void save() {
390         try {
391             // ensure full path exists
392             file.getParentFile().mkdirs();
393
394             final List<String> ipAddresses = systems.values().stream().map(e -> e.getHostAddress())
395                     .collect(Collectors.toList());
396
397             logger.debug("Saving brain's discovered to {}: {}", file.toPath(), StringUtils.join(ipAddresses, ','));
398
399             final String json = gson.toJson(ipAddresses);
400             final byte[] contents = json.getBytes(StandardCharsets.UTF_8);
401             Files.write(file.toPath(), contents);
402         } catch (IOException e) {
403             logger.debug("IOException writing {}: {}", file.toPath(), e.getMessage(), e);
404         }
405     }
406
407     /**
408      * Get's the IP address from the given service
409      *
410      * @param service the non-null {@link ServiceInfo}
411      * @return the ip address of the service or null if not found
412      */
413     @Nullable
414     private InetAddress getIpAddress(ServiceInfo service) {
415         Objects.requireNonNull(service, "service cannot be null");
416
417         for (String addr : service.getHostAddresses()) {
418             try {
419                 return InetAddress.getByName(addr);
420             } catch (UnknownHostException e) {
421                 // ignore
422             }
423         }
424
425         InetAddress address = null;
426         for (InetAddress addr : service.getInet4Addresses()) {
427             return addr;
428         }
429         // Fallback for Inet6addresses
430         for (InetAddress addr : service.getInet6Addresses()) {
431             return addr;
432         }
433         return address;
434     }
435
436     @Override
437     public void close() {
438         context.getMdnsClient().unregisterAllServices();
439         systemsLock.lock();
440         try {
441             save();
442             systems.clear();
443         } finally {
444             systemsLock.unlock();
445         }
446         logger.debug("Stopped NEEO Brain MDNS Listener");
447     }
448 }