]> git.basschouten.com Git - openhab-addons.git/blob
491766eec3243cffa00f368d76eaee28898576aa
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.binding.miio.internal.discovery;
14
15 import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
16
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;
26 import java.util.Map;
27 import java.util.Set;
28 import java.util.concurrent.ConcurrentHashMap;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31
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;
52
53 /**
54  * The {@link MiIoDiscovery} is responsible for discovering new Xiaomi Mi IO devices
55  * and their token
56  *
57  * @author Marcel Verpaalen - Initial contribution
58  *
59  */
60 @NonNullByDefault
61 @Component(service = DiscoveryService.class, configurationPid = "discovery.miio")
62 public class MiIoDiscovery extends AbstractDiscoveryService {
63
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";
71
72     private @Nullable ScheduledFuture<?> miIoDiscoveryJob;
73     protected @Nullable DatagramSocket clientSocket;
74     private @Nullable Thread socketReceiveThread;
75     private Set<String> responseIps = new HashSet<>();
76
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;
81
82     @Activate
83     public MiIoDiscovery(@Reference CloudConnector cloudConnector, @Reference ConfigurationAdmin configAdmin)
84             throws IllegalArgumentException {
85         super(DISCOVERY_TIME);
86         this.cloudConnector = cloudConnector;
87         try {
88             miioConfig = configAdmin.getConfiguration("binding.miio");
89         } catch (IOException | SecurityException e) {
90             logger.debug("Error getting configuration: {}", e.getMessage());
91         }
92     }
93
94     private String getCloudDiscoveryMode() {
95         if (miioConfig != null) {
96             try {
97                 Dictionary<String, @Nullable Object> properties = miioConfig.getProperties();
98                 String cloudDiscoveryModeConfig;
99                 if (properties == null) {
100                     cloudDiscoveryModeConfig = DISABLED;
101                 } else {
102                     cloudDiscoveryModeConfig = (String) properties.get("cloudDiscoveryMode");
103                     if (cloudDiscoveryModeConfig == null) {
104                         cloudDiscoveryModeConfig = DISABLED;
105                     } else {
106                         cloudDiscoveryModeConfig = cloudDiscoveryModeConfig.toLowerCase();
107                     }
108                 }
109                 return Set.of(SUPPORTED, ALL).contains(cloudDiscoveryModeConfig) ? cloudDiscoveryModeConfig : DISABLED;
110             } catch (ClassCastException | SecurityException e) {
111                 logger.debug("Error getting cloud discovery configuration: {}", e.getMessage());
112             }
113         }
114         return DISABLED;
115     }
116
117     @Override
118     public Set<ThingTypeUID> getSupportedThingTypes() {
119         return SUPPORTED_THING_TYPES_UIDS;
120     }
121
122     @Override
123     protected void startBackgroundDiscovery() {
124         logger.debug("Start Xiaomi Mi IO background discovery with cloudDiscoveryMode: {}", getCloudDiscoveryMode());
125         final @Nullable ScheduledFuture<?> miIoDiscoveryJob = this.miIoDiscoveryJob;
126         if (miIoDiscoveryJob == null || miIoDiscoveryJob.isCancelled()) {
127             this.miIoDiscoveryJob = scheduler.scheduleWithFixedDelay(this::discover, 0, SEARCH_INTERVAL,
128                     TimeUnit.SECONDS);
129         }
130     }
131
132     @Override
133     protected void stopBackgroundDiscovery() {
134         logger.debug("Stop Xiaomi  Mi IO background discovery");
135         final @Nullable ScheduledFuture<?> miIoDiscoveryJob = this.miIoDiscoveryJob;
136         if (miIoDiscoveryJob != null) {
137             miIoDiscoveryJob.cancel(true);
138             this.miIoDiscoveryJob = null;
139         }
140     }
141
142     @Override
143     protected void deactivate() {
144         stopReceiverThreat();
145         final DatagramSocket clientSocket = this.clientSocket;
146         if (clientSocket != null) {
147             clientSocket.close();
148         }
149         this.clientSocket = null;
150         super.deactivate();
151     }
152
153     @Override
154     protected void startScan() {
155         String cloudDiscoveryMode = getCloudDiscoveryMode();
156         logger.debug("Start Xiaomi Mi IO discovery with cloudDiscoveryMode: {}", cloudDiscoveryMode);
157         if (!cloudDiscoveryMode.contentEquals(DISABLED)) {
158             cloudDiscovery();
159         }
160         final DatagramSocket clientSocket = getSocket();
161         if (clientSocket != null) {
162             logger.debug("Discovery using socket on port {}", clientSocket.getLocalPort());
163             discover();
164         } else {
165             logger.debug("Discovery not started. Client DatagramSocket null");
166         }
167     }
168
169     private void discover() {
170         startReceiverThreat();
171         responseIps = new HashSet<>();
172         HashSet<String> broadcastAddresses = new HashSet<>();
173         broadcastAddresses.add("224.0.0.1");
174         broadcastAddresses.add("224.0.0.50");
175         broadcastAddresses.addAll(NetUtil.getAllBroadcastAddresses());
176         for (String broadcastAdress : broadcastAddresses) {
177             sendDiscoveryRequest(broadcastAdress);
178         }
179     }
180
181     private void cloudDiscovery() {
182         String cloudDiscoveryMode = getCloudDiscoveryMode();
183         cloudDevices.clear();
184         if (cloudConnector.isConnected()) {
185             List<CloudDeviceDTO> dv = cloudConnector.getDevicesList();
186             for (CloudDeviceDTO device : dv) {
187                 String id = Utils.toHEX(device.getDid());
188                 if (cloudDiscoveryMode.contentEquals(SUPPORTED)) {
189                     if (MiIoDevices.getType(device.getModel()).getThingType().equals(THING_TYPE_UNSUPPORTED)) {
190                         logger.warn("Discovered from cloud, but ignored because not supported: {} {}", id, device);
191                     }
192                 }
193                 if (device.getIsOnline()) {
194                     logger.debug("Discovered from cloud: {} {}", id, device);
195                     cloudDevices.put(id, device.getLocalip());
196                     String token = device.getToken();
197                     String label = device.getName() + " " + id + " (" + device.getDid() + ")";
198                     String country = device.getServer();
199                     boolean isOnline = device.getIsOnline();
200                     String ip = device.getLocalip();
201                     submitDiscovery(ip, token, id, label, country, isOnline);
202                 } else {
203                     logger.debug("Discovered from cloud, but ignored because not online: {} {}", id, device);
204                 }
205             }
206         }
207     }
208
209     private void discovered(String ip, byte[] response) {
210         logger.trace("Discovery responses from : {}:{}", ip, Utils.getSpacedHex(response));
211         Message msg = new Message(response);
212         String token = Utils.getHex(msg.getChecksum());
213         String id = Utils.getHex(msg.getDeviceId());
214         String label = "Xiaomi Mi Device " + id + " (" + Utils.fromHEX(id) + ")";
215         String country = "";
216         boolean isOnline = false;
217         if (ip.equals(cloudDevices.get(id))) {
218             logger.debug("Skipped adding local found {}. Already discovered by cloud.", label);
219             return;
220         }
221         if (cloudConnector.isConnected()) {
222             cloudConnector.getDevicesList();
223             CloudDeviceDTO cloudInfo = cloudConnector.getDeviceInfo(id);
224             if (cloudInfo != null) {
225                 logger.debug("Cloud Info: {}", cloudInfo);
226                 token = cloudInfo.getToken();
227                 label = cloudInfo.getName() + " " + id + " (" + Utils.fromHEX(id) + ")";
228                 country = cloudInfo.getServer();
229                 isOnline = cloudInfo.getIsOnline();
230             }
231         }
232         submitDiscovery(ip, token, id, label, country, isOnline);
233     }
234
235     private void submitDiscovery(String ip, String token, String id, String label, String country, boolean isOnline) {
236         ThingUID uid = new ThingUID(THING_TYPE_MIIO, id.replace(".", "_"));
237         DiscoveryResultBuilder dr = DiscoveryResultBuilder.create(uid).withProperty(PROPERTY_HOST_IP, ip)
238                 .withProperty(PROPERTY_DID, id);
239         if (IGNORED_TOKENS.contains(token)) {
240             logger.debug("Discovered Mi Device {} ({}) at {} as {}", id, Utils.fromHEX(id), ip, uid);
241             logger.debug(
242                     "No token discovered for device {}. For options how to get the token, check the binding readme.",
243                     id);
244             dr = dr.withRepresentationProperty(PROPERTY_DID).withLabel(label);
245         } else {
246             logger.debug("Discovered Mi Device {} ({}) at {} as {} with token {}", id, Utils.fromHEX(id), ip, uid,
247                     token);
248             dr = dr.withProperty(PROPERTY_TOKEN, token).withRepresentationProperty(PROPERTY_DID)
249                     .withLabel(label + " with token");
250         }
251         if (!country.isEmpty() && isOnline) {
252             dr = dr.withProperty(PROPERTY_CLOUDSERVER, country);
253         }
254         thingDiscovered(dr.build());
255     }
256
257     synchronized @Nullable DatagramSocket getSocket() {
258         DatagramSocket clientSocket = this.clientSocket;
259         if (clientSocket != null && clientSocket.isBound()) {
260             return clientSocket;
261         }
262         try {
263             logger.debug("Getting new socket for discovery");
264             clientSocket = new DatagramSocket();
265             clientSocket.setReuseAddress(true);
266             clientSocket.setBroadcast(true);
267             this.clientSocket = clientSocket;
268             return clientSocket;
269         } catch (SocketException | SecurityException e) {
270             logger.debug("Error getting socket for discovery: {}", e.getMessage());
271         }
272         return null;
273     }
274
275     private void closeSocket() {
276         final @Nullable DatagramSocket clientSocket = this.clientSocket;
277         if (clientSocket != null) {
278             clientSocket.close();
279         } else {
280             return;
281         }
282         this.clientSocket = null;
283     }
284
285     private void sendDiscoveryRequest(String ipAddress) {
286         final @Nullable DatagramSocket socket = getSocket();
287         if (socket != null) {
288             try {
289                 byte[] sendData = DISCOVER_STRING;
290                 logger.trace("Discovery sending ping to {} from {}:{}", ipAddress, socket.getLocalAddress(),
291                         socket.getLocalPort());
292                 DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length,
293                         InetAddress.getByName(ipAddress), PORT);
294                 for (int i = 1; i <= 1; i++) {
295                     socket.send(sendPacket);
296                 }
297             } catch (IOException e) {
298                 logger.trace("Discovery on {} error: {}", ipAddress, e.getMessage());
299             }
300         }
301     }
302
303     /**
304      * starts the {@link ReceiverThread} thread
305      */
306     private synchronized void startReceiverThreat() {
307         final Thread srt = socketReceiveThread;
308         if (srt != null) {
309             if (srt.isAlive() && !srt.isInterrupted()) {
310                 return;
311             }
312         }
313         stopReceiverThreat();
314         Thread socketReceiveThread = new ReceiverThread();
315         socketReceiveThread.start();
316         this.socketReceiveThread = socketReceiveThread;
317     }
318
319     /**
320      * Stops the {@link ReceiverThread} thread
321      */
322     private synchronized void stopReceiverThreat() {
323         if (socketReceiveThread != null) {
324             socketReceiveThread.interrupt();
325             socketReceiveThread = null;
326         }
327         closeSocket();
328     }
329
330     /**
331      * The thread, which waits for data and submits the unique results addresses to the discovery results
332      *
333      */
334     private class ReceiverThread extends Thread {
335         @Override
336         public void run() {
337             DatagramSocket socket = getSocket();
338             if (socket != null) {
339                 logger.debug("Starting discovery receiver thread for socket on port {}", socket.getLocalPort());
340                 receiveData(socket);
341             }
342         }
343
344         /**
345          * This method waits for data and submits the unique results addresses to the discovery results
346          *
347          * @param socket - The multicast socket to (re)use
348          */
349         private void receiveData(DatagramSocket socket) {
350             DatagramPacket receivePacket = new DatagramPacket(new byte[BUFFER_LENGTH], BUFFER_LENGTH);
351             try {
352                 while (!interrupted()) {
353                     logger.trace("Thread {} waiting for data on port {}", this, socket.getLocalPort());
354                     socket.receive(receivePacket);
355                     String hostAddress = receivePacket.getAddress().getHostAddress();
356                     logger.trace("Received {} bytes response from {}:{} on Port {}", receivePacket.getLength(),
357                             hostAddress, receivePacket.getPort(), socket.getLocalPort());
358
359                     byte[] messageBuf = Arrays.copyOfRange(receivePacket.getData(), receivePacket.getOffset(),
360                             receivePacket.getOffset() + receivePacket.getLength());
361                     if (logger.isTraceEnabled()) {
362                         Message miIoResponse = new Message(messageBuf);
363                         logger.trace("Discovery response received from {} DeviceID: {}\r\n{}", hostAddress,
364                                 Utils.getHex(miIoResponse.getDeviceId()), miIoResponse.toSting());
365                     }
366                     if (!responseIps.contains(hostAddress)) {
367                         scheduler.schedule(() -> {
368                             try {
369                                 discovered(hostAddress, messageBuf);
370                             } catch (Exception e) {
371                                 logger.debug("Error submitting discovered Mi IO device at {}", hostAddress, e);
372                             }
373                         }, 0, TimeUnit.SECONDS);
374                     }
375                     responseIps.add(hostAddress);
376                 }
377             } catch (SocketException e) {
378                 logger.debug("Receiver thread received SocketException: {}", e.getMessage());
379             } catch (IOException e) {
380                 logger.trace("Receiver thread was interrupted");
381             }
382             logger.debug("Receiver thread ended");
383         }
384     }
385 }