2 * Copyright (c) 2010-2020 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;
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;
51 import com.google.gson.Gson;
52 import com.google.gson.JsonParseException;
55 * An implementations of {@link BrainDiscovery} that will discovery brains from the MDNS/Zeroconf/Bonjour service
58 * @author Tim Roberts - Initial Contribution
61 public class MdnsBrainDiscovery extends AbstractBrainDiscovery {
64 private final Logger logger = LoggerFactory.getLogger(MdnsBrainDiscovery.class);
66 /** The lock that controls access to the {@link #systems} set */
67 private final Lock systemsLock = new ReentrantLock();
69 /** The set of {@link NeeoSystemInfo} that has been discovered */
70 private final Map<NeeoSystemInfo, InetAddress> systems = new HashMap<>();
72 /** The MDNS listener used. */
73 private final ServiceListener mdnsListener = new ServiceListener() {
76 public void serviceAdded(@Nullable ServiceEvent event) {
78 considerService(event.getInfo());
83 public void serviceRemoved(@Nullable ServiceEvent event) {
85 removeService(event.getInfo());
90 public void serviceResolved(@Nullable ServiceEvent event) {
92 considerService(event.getInfo());
97 /** The service context */
98 private final ServiceContext context;
100 /** The scheduler used to schedule tasks */
101 private final ScheduledExecutorService scheduler = ThreadPoolManager
102 .getScheduledPool(NeeoConstants.THREAD_POOL_NAME);
104 private final Gson gson = new Gson();
106 /** The file we store definitions in */
107 private final File file = new File(NeeoConstants.FILENAME_DISCOVEREDBRAINS);
110 * Creates the MDNS brain discovery from the given {@link ServiceContext}
112 * @param context the non-null service context
114 public MdnsBrainDiscovery(ServiceContext context) {
115 Objects.requireNonNull(context, "context cannot be null");
116 this.context = context;
120 * Starts discovery by
122 * <li>Listening to future service announcements from the {@link MDNSClient}</li>
123 * <li>Getting a list of all current announcements</li>
128 public void startDiscovery() {
129 logger.debug("Starting NEEO Brain MDNS Listener");
130 context.getMdnsClient().addServiceListener(NeeoConstants.NEEO_MDNS_TYPE, mdnsListener);
132 scheduler.execute(() -> {
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);
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);
155 for (ServiceInfo info : context.getMdnsClient().list(NeeoConstants.NEEO_MDNS_TYPE)) {
156 considerService(info);
162 public void addListener(DiscoveryListener listener) {
163 super.addListener(listener);
166 for (Entry<NeeoSystemInfo, InetAddress> entry : systems.entrySet()) {
167 listener.discovered(entry.getKey(), entry.getValue());
170 systemsLock.unlock();
175 * Return the brain ID and {@link InetAddress} from the {@link ServiceInfo}
177 * @param info the non-null {@link ServiceInfo}
178 * @return an {@link Entry} that represents the brain ID and the associated IP address
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);
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);
194 String model = info.getPropertyString("hon"); // model
196 final String server = info.getServer(); // NEEO-xxxxx.local.
197 if (server != null) {
198 final int idx = server.indexOf(".");
200 model = server.substring(0, idx);
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);
209 return new AbstractMap.SimpleImmutableEntry<>(model, ipAddress);
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).
216 * @param info the non-null {@link ServiceInfo}
218 private void considerService(ServiceInfo info) {
219 considerService(info, 1);
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.
228 * @param info the non-null {@link ServiceInfo}
229 * @param attempts the number of attempts that have been made
231 private void considerService(ServiceInfo info, int attempts) {
232 Objects.requireNonNull(info, "info cannot be null");
234 throw new IllegalArgumentException("attempts cannot be below 1: " + attempts);
237 final Entry<String, InetAddress> brainInfo = getNeeoBrainInfo(info);
238 if (brainInfo == null) {
239 logger.debug("BrainInfo null (ignoring): {}", info);
243 logger.debug("NEEO Brain Found: {} (attempt #{} to get information)", brainInfo.getKey(), attempts);
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());
251 NeeoSystemInfo sysInfo;
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);
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);
272 } else if (!oldAddr.equals(newAddr)) {
273 fireRemoved(sysInfo);
274 systems.put(sysInfo, newAddr);
275 fireUpdated(sysInfo, oldAddr, newAddr);
278 logger.debug("NEEO Brain {} already registered", brainInfo.getValue());
281 systemsLock.unlock();
286 public boolean addDiscovered(String ipAddress) {
287 return addDiscovered(ipAddress, true);
291 * Adds a discovered IP address and optionally saving it to the brain's discovered file
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
297 private boolean addDiscovered(String ipAddress, boolean save) {
298 NeeoUtil.requireNotEmpty(ipAddress, "ipAddress cannot be empty");
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);
307 final InetAddress oldAddr = systems.get(sysInfo);
309 systems.put(sysInfo, addr);
311 if (oldAddr == null) {
312 fireDiscovered(sysInfo, addr);
314 fireUpdated(sysInfo, oldAddr, addr);
320 systemsLock.unlock();
324 } catch (IOException e) {
325 logger.debug("Tried to manually add a brain ({}) but an exception occurred: {}", ipAddress, e.getMessage(),
332 public boolean removeDiscovered(String servletUrl) {
333 NeeoUtil.requireNotEmpty(servletUrl, "servletUrl cannot be null");
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());
344 logger.debug("Tried to remove a servlet for {} but none were found - ignored.", servletUrl);
348 systemsLock.unlock();
353 * Removes the service. If the info represents a brain we already discovered, a {@link #fireRemoved(NeeoSystemInfo)}
356 * @param info the non-null {@link ServiceInfo}
358 private void removeService(ServiceInfo info) {
359 Objects.requireNonNull(info, "info cannot be null");
361 final Entry<String, InetAddress> brainInfo = getNeeoBrainInfo(info);
362 if (brainInfo == null) {
368 NeeoSystemInfo foundInfo = null;
369 for (NeeoSystemInfo existingSysInfo : systems.keySet()) {
370 if (StringUtils.equals(existingSysInfo.getHostname(), brainInfo.getKey())) {
371 foundInfo = existingSysInfo;
375 if (foundInfo != null) {
376 fireRemoved(foundInfo);
377 systems.remove(foundInfo);
381 systemsLock.unlock();
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}
389 private void save() {
391 // ensure full path exists
392 file.getParentFile().mkdirs();
394 final List<String> ipAddresses = systems.values().stream().map(e -> e.getHostAddress())
395 .collect(Collectors.toList());
397 logger.debug("Saving brain's discovered to {}: {}", file.toPath(), StringUtils.join(ipAddresses, ','));
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);
408 * Get's the IP address from the given service
410 * @param service the non-null {@link ServiceInfo}
411 * @return the ip address of the service or null if not found
414 private InetAddress getIpAddress(ServiceInfo service) {
415 Objects.requireNonNull(service, "service cannot be null");
417 for (String addr : service.getHostAddresses()) {
419 return InetAddress.getByName(addr);
420 } catch (UnknownHostException e) {
425 InetAddress address = null;
426 for (InetAddress addr : service.getInet4Addresses()) {
429 // Fallback for Inet6addresses
430 for (InetAddress addr : service.getInet6Addresses()) {
437 public void close() {
438 context.getMdnsClient().unregisterAllServices();
444 systemsLock.unlock();
446 logger.debug("Stopped NEEO Brain MDNS Listener");