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