2 * Copyright (c) 2010-2023 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.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;
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);
109 private final HttpClient httpClient;
112 * Creates the MDNS brain discovery from the given {@link ServiceContext}
114 * @param context the non-null service context
116 public MdnsBrainDiscovery(ServiceContext context, HttpClient httpClient) {
117 Objects.requireNonNull(context, "context cannot be null");
118 this.context = context;
119 this.httpClient = httpClient;
123 * Starts discovery by
125 * <li>Listening to future service announcements from the {@link MDNSClient}</li>
126 * <li>Getting a list of all current announcements</li>
131 public void startDiscovery() {
132 logger.debug("Starting NEEO Brain MDNS Listener");
133 context.getMdnsClient().addServiceListener(NeeoConstants.NEEO_MDNS_TYPE, mdnsListener);
135 scheduler.execute(() -> {
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);
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);
158 for (ServiceInfo info : context.getMdnsClient().list(NeeoConstants.NEEO_MDNS_TYPE)) {
159 considerService(info);
165 public void addListener(DiscoveryListener listener) {
166 super.addListener(listener);
169 for (Entry<NeeoSystemInfo, InetAddress> entry : systems.entrySet()) {
170 listener.discovered(entry.getKey(), entry.getValue());
173 systemsLock.unlock();
178 * Return the brain ID and {@link InetAddress} from the {@link ServiceInfo}
180 * @param info the non-null {@link ServiceInfo}
181 * @return an {@link Entry} that represents the brain ID and the associated IP address
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);
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);
197 String model = info.getPropertyString("hon"); // model
199 final String server = info.getServer(); // NEEO-xxxxx.local.
200 if (server != null) {
201 final int idx = server.indexOf(".");
203 model = server.substring(0, idx);
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);
212 return new AbstractMap.SimpleImmutableEntry<>(model, ipAddress);
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).
219 * @param info the non-null {@link ServiceInfo}
221 private void considerService(ServiceInfo info) {
222 considerService(info, 1);
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.
231 * @param info the non-null {@link ServiceInfo}
232 * @param attempts the number of attempts that have been made
234 private void considerService(ServiceInfo info, int attempts) {
235 Objects.requireNonNull(info, "info cannot be null");
237 throw new IllegalArgumentException("attempts cannot be below 1: " + attempts);
240 final Entry<String, InetAddress> brainInfo = getNeeoBrainInfo(info);
241 if (brainInfo == null) {
242 logger.debug("BrainInfo null (ignoring): {}", info);
246 logger.debug("NEEO Brain Found: {} (attempt #{} to get information)", brainInfo.getKey(), attempts);
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());
254 NeeoSystemInfo sysInfo;
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);
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);
275 } else if (!oldAddr.equals(newAddr)) {
276 fireRemoved(sysInfo);
277 systems.put(sysInfo, newAddr);
278 fireUpdated(sysInfo, oldAddr, newAddr);
281 logger.debug("NEEO Brain {} already registered", brainInfo.getValue());
284 systemsLock.unlock();
289 public boolean addDiscovered(String ipAddress) {
290 return addDiscovered(ipAddress, true);
294 * Adds a discovered IP address and optionally saving it to the brain's discovered file
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
300 private boolean addDiscovered(String ipAddress, boolean save) {
301 NeeoUtil.requireNotEmpty(ipAddress, "ipAddress cannot be empty");
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);
310 final InetAddress oldAddr = systems.get(sysInfo);
312 systems.put(sysInfo, addr);
314 if (oldAddr == null) {
315 fireDiscovered(sysInfo, addr);
317 fireUpdated(sysInfo, oldAddr, addr);
323 systemsLock.unlock();
327 } catch (IOException e) {
328 logger.debug("Tried to manually add a brain ({}) but an exception occurred: {}", ipAddress, e.getMessage(),
335 public boolean removeDiscovered(String servletUrl) {
336 NeeoUtil.requireNotEmpty(servletUrl, "servletUrl cannot be null");
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());
347 logger.debug("Tried to remove a servlet for {} but none were found - ignored.", servletUrl);
351 systemsLock.unlock();
356 * Removes the service. If the info represents a brain we already discovered, a {@link #fireRemoved(NeeoSystemInfo)}
359 * @param info the non-null {@link ServiceInfo}
361 private void removeService(ServiceInfo info) {
362 Objects.requireNonNull(info, "info cannot be null");
364 final Entry<String, InetAddress> brainInfo = getNeeoBrainInfo(info);
365 if (brainInfo == null) {
371 NeeoSystemInfo foundInfo = null;
372 for (NeeoSystemInfo existingSysInfo : systems.keySet()) {
373 if (existingSysInfo.getHostname().equals(brainInfo.getKey())) {
374 foundInfo = existingSysInfo;
378 if (foundInfo != null) {
379 fireRemoved(foundInfo);
380 systems.remove(foundInfo);
384 systemsLock.unlock();
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}
392 private void save() {
394 // ensure full path exists
395 file.getParentFile().mkdirs();
397 final List<String> ipAddresses = systems.values().stream().map(e -> e.getHostAddress())
398 .collect(Collectors.toList());
400 logger.debug("Saving brain's discovered to {}: {}", file.toPath(), String.join(",", ipAddresses));
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);
411 * Get's the IP address from the given service
413 * @param service the non-null {@link ServiceInfo}
414 * @return the ip address of the service or null if not found
417 private InetAddress getIpAddress(ServiceInfo service) {
418 Objects.requireNonNull(service, "service cannot be null");
420 for (String addr : service.getHostAddresses()) {
422 return InetAddress.getByName(addr);
423 } catch (UnknownHostException e) {
428 InetAddress address = null;
429 for (InetAddress addr : service.getInet4Addresses()) {
432 // Fallback for Inet6addresses
433 for (InetAddress addr : service.getInet6Addresses()) {
440 public void close() {
441 context.getMdnsClient().unregisterAllServices();
447 systemsLock.unlock();
449 logger.debug("Stopped NEEO Brain MDNS Listener");