2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.io.neeo.internal.discovery;
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;
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;
34 import javax.jmdns.ServiceEvent;
35 import javax.jmdns.ServiceInfo;
36 import javax.jmdns.ServiceListener;
37 import javax.ws.rs.client.ClientBuilder;
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;
52 import com.google.gson.Gson;
53 import com.google.gson.JsonParseException;
56 * An implementations of {@link BrainDiscovery} that will discovery brains from the MDNS/Zeroconf/Bonjour service
59 * @author Tim Roberts - Initial Contribution
62 public class MdnsBrainDiscovery extends AbstractBrainDiscovery {
65 private final Logger logger = LoggerFactory.getLogger(MdnsBrainDiscovery.class);
67 /** The lock that controls access to the {@link #systems} set */
68 private final Lock systemsLock = new ReentrantLock();
70 /** The set of {@link NeeoSystemInfo} that has been discovered */
71 private final Map<NeeoSystemInfo, InetAddress> systems = new HashMap<>();
73 /** The MDNS listener used. */
74 private final ServiceListener mdnsListener = new ServiceListener() {
77 public void serviceAdded(@Nullable ServiceEvent event) {
79 considerService(event.getInfo());
84 public void serviceRemoved(@Nullable ServiceEvent event) {
86 removeService(event.getInfo());
91 public void serviceResolved(@Nullable ServiceEvent event) {
93 considerService(event.getInfo());
98 /** The service context */
99 private final ServiceContext context;
101 /** The scheduler used to schedule tasks */
102 private final ScheduledExecutorService scheduler = ThreadPoolManager
103 .getScheduledPool(NeeoConstants.THREAD_POOL_NAME);
105 private final Gson gson = new Gson();
107 /** The file we store definitions in */
108 private final File file = new File(NeeoConstants.FILENAME_DISCOVEREDBRAINS);
110 private final ClientBuilder clientBuilder;
113 * Creates the MDNS brain discovery from the given {@link ServiceContext}
115 * @param context the non-null service context
117 public MdnsBrainDiscovery(ServiceContext context, ClientBuilder clientBuilder) {
118 Objects.requireNonNull(context, "context cannot be null");
119 this.context = context;
120 this.clientBuilder = clientBuilder;
124 * Starts discovery by
126 * <li>Listening to future service announcements from the {@link MDNSClient}</li>
127 * <li>Getting a list of all current announcements</li>
132 public void startDiscovery() {
133 logger.debug("Starting NEEO Brain MDNS Listener");
134 context.getMdnsClient().addServiceListener(NeeoConstants.NEEO_MDNS_TYPE, mdnsListener);
136 scheduler.execute(() -> {
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);
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);
159 for (ServiceInfo info : context.getMdnsClient().list(NeeoConstants.NEEO_MDNS_TYPE)) {
160 considerService(info);
166 public void addListener(DiscoveryListener listener) {
167 super.addListener(listener);
170 for (Entry<NeeoSystemInfo, InetAddress> entry : systems.entrySet()) {
171 listener.discovered(entry.getKey(), entry.getValue());
174 systemsLock.unlock();
179 * Return the brain ID and {@link InetAddress} from the {@link ServiceInfo}
181 * @param info the non-null {@link ServiceInfo}
182 * @return an {@link Entry} that represents the brain ID and the associated IP address
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);
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);
198 String model = info.getPropertyString("hon"); // model
200 final String server = info.getServer(); // NEEO-xxxxx.local.
201 if (server != null) {
202 final int idx = server.indexOf(".");
204 model = server.substring(0, idx);
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);
213 return new AbstractMap.SimpleImmutableEntry<>(model, ipAddress);
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).
220 * @param info the non-null {@link ServiceInfo}
222 private void considerService(ServiceInfo info) {
223 considerService(info, 1);
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.
232 * @param info the non-null {@link ServiceInfo}
233 * @param attempts the number of attempts that have been made
235 private void considerService(ServiceInfo info, int attempts) {
236 Objects.requireNonNull(info, "info cannot be null");
238 throw new IllegalArgumentException("attempts cannot be below 1: " + attempts);
241 final Entry<String, InetAddress> brainInfo = getNeeoBrainInfo(info);
242 if (brainInfo == null) {
243 logger.debug("BrainInfo null (ignoring): {}", info);
247 logger.debug("NEEO Brain Found: {} (attempt #{} to get information)", brainInfo.getKey(), attempts);
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());
255 NeeoSystemInfo sysInfo;
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);
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);
276 } else if (!oldAddr.equals(newAddr)) {
277 fireRemoved(sysInfo);
278 systems.put(sysInfo, newAddr);
279 fireUpdated(sysInfo, oldAddr, newAddr);
282 logger.debug("NEEO Brain {} already registered", brainInfo.getValue());
285 systemsLock.unlock();
290 public boolean addDiscovered(String ipAddress) {
291 return addDiscovered(ipAddress, true);
295 * Adds a discovered IP address and optionally saving it to the brain's discovered file
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
301 private boolean addDiscovered(String ipAddress, boolean save) {
302 NeeoUtil.requireNotEmpty(ipAddress, "ipAddress cannot be empty");
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);
311 final InetAddress oldAddr = systems.get(sysInfo);
313 systems.put(sysInfo, addr);
315 if (oldAddr == null) {
316 fireDiscovered(sysInfo, addr);
318 fireUpdated(sysInfo, oldAddr, addr);
324 systemsLock.unlock();
328 } catch (IOException e) {
329 logger.debug("Tried to manually add a brain ({}) but an exception occurred: {}", ipAddress, e.getMessage(),
336 public boolean removeDiscovered(String servletUrl) {
337 NeeoUtil.requireNotEmpty(servletUrl, "servletUrl cannot be null");
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());
348 logger.debug("Tried to remove a servlet for {} but none were found - ignored.", servletUrl);
352 systemsLock.unlock();
357 * Removes the service. If the info represents a brain we already discovered, a {@link #fireRemoved(NeeoSystemInfo)}
360 * @param info the non-null {@link ServiceInfo}
362 private void removeService(ServiceInfo info) {
363 Objects.requireNonNull(info, "info cannot be null");
365 final Entry<String, InetAddress> brainInfo = getNeeoBrainInfo(info);
366 if (brainInfo == null) {
372 NeeoSystemInfo foundInfo = null;
373 for (NeeoSystemInfo existingSysInfo : systems.keySet()) {
374 if (StringUtils.equals(existingSysInfo.getHostname(), brainInfo.getKey())) {
375 foundInfo = existingSysInfo;
379 if (foundInfo != null) {
380 fireRemoved(foundInfo);
381 systems.remove(foundInfo);
385 systemsLock.unlock();
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}
393 private void save() {
395 // ensure full path exists
396 file.getParentFile().mkdirs();
398 final List<String> ipAddresses = systems.values().stream().map(e -> e.getHostAddress())
399 .collect(Collectors.toList());
401 logger.debug("Saving brain's discovered to {}: {}", file.toPath(), StringUtils.join(ipAddresses, ','));
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);
412 * Get's the IP address from the given service
414 * @param service the non-null {@link ServiceInfo}
415 * @return the ip address of the service or null if not found
418 private InetAddress getIpAddress(ServiceInfo service) {
419 Objects.requireNonNull(service, "service cannot be null");
421 for (String addr : service.getHostAddresses()) {
423 return InetAddress.getByName(addr);
424 } catch (UnknownHostException e) {
429 InetAddress address = null;
430 for (InetAddress addr : service.getInet4Addresses()) {
433 // Fallback for Inet6addresses
434 for (InetAddress addr : service.getInet6Addresses()) {
441 public void close() {
442 context.getMdnsClient().unregisterAllServices();
448 systemsLock.unlock();
450 logger.debug("Stopped NEEO Brain MDNS Listener");