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