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.binding.miio.internal.discovery;
15 import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
17 import java.io.IOException;
18 import java.net.DatagramPacket;
19 import java.net.DatagramSocket;
20 import java.net.InetAddress;
21 import java.net.SocketException;
22 import java.util.Arrays;
23 import java.util.Dictionary;
24 import java.util.HashSet;
25 import java.util.List;
28 import java.util.concurrent.ConcurrentHashMap;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.miio.internal.Message;
35 import org.openhab.binding.miio.internal.MiIoDevices;
36 import org.openhab.binding.miio.internal.Utils;
37 import org.openhab.binding.miio.internal.cloud.CloudConnector;
38 import org.openhab.binding.miio.internal.cloud.CloudDeviceDTO;
39 import org.openhab.core.config.discovery.AbstractDiscoveryService;
40 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
41 import org.openhab.core.config.discovery.DiscoveryService;
42 import org.openhab.core.net.NetUtil;
43 import org.openhab.core.thing.ThingTypeUID;
44 import org.openhab.core.thing.ThingUID;
45 import org.osgi.service.cm.Configuration;
46 import org.osgi.service.cm.ConfigurationAdmin;
47 import org.osgi.service.component.annotations.Activate;
48 import org.osgi.service.component.annotations.Component;
49 import org.osgi.service.component.annotations.Reference;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
54 * The {@link MiIoDiscovery} is responsible for discovering new Xiaomi Mi IO devices
57 * @author Marcel Verpaalen - Initial contribution
61 @Component(service = DiscoveryService.class, configurationPid = "discovery.miio")
62 public class MiIoDiscovery extends AbstractDiscoveryService {
64 /** The refresh interval for background discovery */
65 private static final long SEARCH_INTERVAL = 600;
66 private static final int BUFFER_LENGTH = 1024;
67 private static final int DISCOVERY_TIME = 10;
68 private static final String DISABLED = "disabled";
69 private static final String SUPPORTED = "supportedonly";
70 private static final String ALL = "all";
72 private @Nullable ScheduledFuture<?> miIoDiscoveryJob;
73 protected @Nullable DatagramSocket clientSocket;
74 private @Nullable Thread socketReceiveThread;
75 private Set<String> responseIps = new HashSet<>();
77 private final Logger logger = LoggerFactory.getLogger(MiIoDiscovery.class);
78 private final CloudConnector cloudConnector;
79 private Map<String, String> cloudDevices = new ConcurrentHashMap<>();
80 private @Nullable Configuration miioConfig;
83 public MiIoDiscovery(@Reference CloudConnector cloudConnector, @Reference ConfigurationAdmin configAdmin)
84 throws IllegalArgumentException {
85 super(DISCOVERY_TIME);
86 this.cloudConnector = cloudConnector;
88 miioConfig = configAdmin.getConfiguration("binding.miio");
89 } catch (IOException | SecurityException e) {
90 logger.debug("Error getting configuration: {}", e.getMessage());
94 private String getCloudDiscoveryMode() {
95 final Configuration miioConfig = this.miioConfig;
96 if (miioConfig != null) {
98 Dictionary<String, @Nullable Object> properties = miioConfig.getProperties();
99 String cloudDiscoveryModeConfig;
100 if (properties == null) {
101 cloudDiscoveryModeConfig = DISABLED;
103 cloudDiscoveryModeConfig = (String) properties.get("cloudDiscoveryMode");
104 if (cloudDiscoveryModeConfig == null) {
105 cloudDiscoveryModeConfig = DISABLED;
107 cloudDiscoveryModeConfig = cloudDiscoveryModeConfig.toLowerCase();
110 return Set.of(SUPPORTED, ALL).contains(cloudDiscoveryModeConfig) ? cloudDiscoveryModeConfig : DISABLED;
111 } catch (ClassCastException | SecurityException e) {
112 logger.debug("Error getting cloud discovery configuration: {}", e.getMessage());
119 public Set<ThingTypeUID> getSupportedThingTypes() {
120 return SUPPORTED_THING_TYPES_UIDS;
124 protected void startBackgroundDiscovery() {
125 logger.debug("Start Xiaomi Mi IO background discovery with cloudDiscoveryMode: {}", getCloudDiscoveryMode());
126 final @Nullable ScheduledFuture<?> miIoDiscoveryJob = this.miIoDiscoveryJob;
127 if (miIoDiscoveryJob == null || miIoDiscoveryJob.isCancelled()) {
128 this.miIoDiscoveryJob = scheduler.scheduleWithFixedDelay(this::discover, 0, SEARCH_INTERVAL,
134 protected void stopBackgroundDiscovery() {
135 logger.debug("Stop Xiaomi Mi IO background discovery");
136 final @Nullable ScheduledFuture<?> miIoDiscoveryJob = this.miIoDiscoveryJob;
137 if (miIoDiscoveryJob != null) {
138 miIoDiscoveryJob.cancel(true);
139 this.miIoDiscoveryJob = null;
144 protected void deactivate() {
145 stopReceiverThreat();
146 final DatagramSocket clientSocket = this.clientSocket;
147 if (clientSocket != null) {
148 clientSocket.close();
150 this.clientSocket = null;
155 protected void startScan() {
156 String cloudDiscoveryMode = getCloudDiscoveryMode();
157 logger.debug("Start Xiaomi Mi IO discovery with cloudDiscoveryMode: {}", cloudDiscoveryMode);
158 if (!cloudDiscoveryMode.contentEquals(DISABLED)) {
161 final DatagramSocket clientSocket = getSocket();
162 if (clientSocket != null) {
163 logger.debug("Discovery using socket on port {}", clientSocket.getLocalPort());
166 logger.debug("Discovery not started. Client DatagramSocket null");
170 private void discover() {
171 startReceiverThreat();
172 responseIps = new HashSet<>();
173 HashSet<String> broadcastAddresses = new HashSet<>();
174 broadcastAddresses.add("224.0.0.1");
175 broadcastAddresses.add("224.0.0.50");
176 broadcastAddresses.addAll(NetUtil.getAllBroadcastAddresses());
177 for (String broadcastAdress : broadcastAddresses) {
178 sendDiscoveryRequest(broadcastAdress);
182 private void cloudDiscovery() {
183 String cloudDiscoveryMode = getCloudDiscoveryMode();
184 cloudDevices.clear();
185 if (cloudConnector.isConnected()) {
186 List<CloudDeviceDTO> dv = cloudConnector.getDevicesList();
187 for (CloudDeviceDTO device : dv) {
188 String id = device.getDid();
189 if (SUPPORTED.contentEquals(cloudDiscoveryMode)) {
190 if (MiIoDevices.getType(device.getModel()).getThingType().equals(THING_TYPE_UNSUPPORTED)) {
191 logger.debug("Discovered from cloud, but ignored because not supported: {} {}", id, device);
194 if (device.getIsOnline() || ALL.contentEquals(cloudDiscoveryMode)) {
195 logger.debug("Discovered from cloud: {} {}", id, device);
196 cloudDevices.put(id, device.getLocalip());
197 String token = device.getToken();
198 String label = device.getName() + " (" + id + (id.contains(".") ? "" : " / " + Utils.getHexId(id))
200 String model = device.getModel();
201 String country = device.getServer();
202 boolean isOnline = device.getIsOnline();
203 String parent = device.getParentId();
204 String ip = device.getLocalip();
205 submitDiscovery(ip, token, id, label, model, country, isOnline, parent);
207 logger.debug("Discovered from cloud, but ignored because not online: {} {}", id, device);
213 private void discovered(String ip, byte[] response) {
214 logger.trace("Discovery responses from : {}:{}", ip, Utils.getSpacedHex(response));
215 Message msg = new Message(response);
216 String token = Utils.getHex(msg.getChecksum());
217 String hexId = Utils.getHex(msg.getDeviceId());
218 String id = Utils.fromHEX(hexId);
219 String label = "Xiaomi Mi Device " + " (" + id + (id.contains(".") ? "" : " / " + Utils.getHexId(id)) + ")";
223 boolean isOnline = false;
224 if (ip.equals(cloudDevices.get(id))) {
225 logger.debug("Skipped adding local found {}. Already discovered by cloud.", label);
228 if (cloudConnector.isConnected()) {
229 cloudConnector.getDevicesList();
230 CloudDeviceDTO cloudInfo = cloudConnector.getDeviceInfo(id);
231 if (cloudInfo != null) {
232 logger.debug("Cloud Info: {}", cloudInfo);
233 token = cloudInfo.getToken();
234 label = cloudInfo.getName() + " " + id + " (" + Utils.getHexId(id) + ")";
235 model = cloudInfo.getModel();
236 country = cloudInfo.getServer();
237 isOnline = cloudInfo.getIsOnline();
238 parent = cloudInfo.getParentId();
241 submitDiscovery(ip, token, id, label, model, country, isOnline, parent);
244 private void submitDiscovery(String ip, String token, String id, String label, String model, String country,
245 boolean isOnline, String parent) {
247 ThingTypeUID thingType = MiIoDevices.getType(model).getThingType();
248 if (id.startsWith("lumi.") || THING_TYPE_GATEWAY.equals(thingType) || THING_TYPE_LUMI.equals(thingType)) {
249 uid = new ThingUID(thingType, Utils.getHexId(id).replace(".", "_"));
251 uid = new ThingUID(THING_TYPE_MIIO, Utils.getHexId(id).replace(".", "_"));
253 DiscoveryResultBuilder dr = DiscoveryResultBuilder.create(uid).withProperty(PROPERTY_HOST_IP, ip)
254 .withProperty(PROPERTY_DID, id);
255 if (IGNORED_TOKENS.contains(token) || token.isBlank()) {
256 logger.debug("Discovered Mi Device {} ({}) at {} as {}", id, Utils.getHexId(id), ip, uid);
258 "No token discovered for device {}. For options how to get the token, check the binding readme.",
260 dr = dr.withRepresentationProperty(PROPERTY_DID).withLabel(label);
262 logger.debug("Discovered Mi Device {} ({}) at {} as {} with token {}", id, Utils.getHexId(id), ip, uid,
264 dr = dr.withProperty(PROPERTY_TOKEN, token).withRepresentationProperty(PROPERTY_DID)
265 .withLabel(label + " with token");
267 if (!model.isEmpty()) {
268 dr = dr.withProperty(PROPERTY_MODEL, model);
270 if (!country.isEmpty() && isOnline) {
271 dr = dr.withProperty(PROPERTY_CLOUDSERVER, country);
273 thingDiscovered(dr.build());
276 synchronized @Nullable DatagramSocket getSocket() {
277 DatagramSocket clientSocket = this.clientSocket;
278 if (clientSocket != null && clientSocket.isBound()) {
282 logger.debug("Getting new socket for discovery");
283 clientSocket = new DatagramSocket();
284 clientSocket.setReuseAddress(true);
285 clientSocket.setBroadcast(true);
286 this.clientSocket = clientSocket;
288 } catch (SocketException | SecurityException e) {
289 logger.debug("Error getting socket for discovery: {}", e.getMessage());
294 private void closeSocket() {
295 final @Nullable DatagramSocket clientSocket = this.clientSocket;
296 if (clientSocket != null) {
297 clientSocket.close();
301 this.clientSocket = null;
304 private void sendDiscoveryRequest(String ipAddress) {
305 final @Nullable DatagramSocket socket = getSocket();
306 if (socket != null) {
308 byte[] sendData = DISCOVER_STRING;
309 logger.trace("Discovery sending ping to {} from {}:{}", ipAddress, socket.getLocalAddress(),
310 socket.getLocalPort());
311 DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length,
312 InetAddress.getByName(ipAddress), PORT);
313 for (int i = 1; i <= 1; i++) {
314 socket.send(sendPacket);
316 } catch (IOException e) {
317 logger.trace("Discovery on {} error: {}", ipAddress, e.getMessage());
323 * starts the {@link ReceiverThread} thread
325 private synchronized void startReceiverThreat() {
326 final Thread srt = socketReceiveThread;
328 if (srt.isAlive() && !srt.isInterrupted()) {
332 stopReceiverThreat();
333 Thread socketReceiveThread = new ReceiverThread();
334 socketReceiveThread.start();
335 this.socketReceiveThread = socketReceiveThread;
339 * Stops the {@link ReceiverThread} thread
341 private synchronized void stopReceiverThreat() {
342 final Thread socketReceiveThread = this.socketReceiveThread;
343 if (socketReceiveThread != null) {
344 socketReceiveThread.interrupt();
345 this.socketReceiveThread = null;
351 * The thread, which waits for data and submits the unique results addresses to the discovery results
354 private class ReceiverThread extends Thread {
357 DatagramSocket socket = getSocket();
358 if (socket != null) {
359 logger.debug("Starting discovery receiver thread for socket on port {}", socket.getLocalPort());
365 * This method waits for data and submits the unique results addresses to the discovery results
367 * @param socket - The multicast socket to (re)use
369 private void receiveData(DatagramSocket socket) {
370 DatagramPacket receivePacket = new DatagramPacket(new byte[BUFFER_LENGTH], BUFFER_LENGTH);
372 while (!interrupted()) {
373 logger.trace("Thread {} waiting for data on port {}", this, socket.getLocalPort());
374 socket.receive(receivePacket);
375 String hostAddress = receivePacket.getAddress().getHostAddress();
376 logger.trace("Received {} bytes response from {}:{} on Port {}", receivePacket.getLength(),
377 hostAddress, receivePacket.getPort(), socket.getLocalPort());
379 byte[] messageBuf = Arrays.copyOfRange(receivePacket.getData(), receivePacket.getOffset(),
380 receivePacket.getOffset() + receivePacket.getLength());
381 if (logger.isTraceEnabled()) {
382 Message miIoResponse = new Message(messageBuf);
383 logger.trace("Discovery response received from {} DeviceID: {}\r\n{}", hostAddress,
384 Utils.getHex(miIoResponse.getDeviceId()), miIoResponse.toSting());
386 if (!responseIps.contains(hostAddress)) {
387 scheduler.schedule(() -> {
389 discovered(hostAddress, messageBuf);
390 } catch (Exception e) {
391 logger.debug("Error submitting discovered Mi IO device at {}", hostAddress, e);
393 }, 0, TimeUnit.SECONDS);
395 responseIps.add(hostAddress);
397 } catch (SocketException e) {
398 logger.debug("Receiver thread received SocketException: {}", e.getMessage());
399 } catch (IOException e) {
400 logger.trace("Receiver thread was interrupted");
402 logger.debug("Receiver thread ended");