]> git.basschouten.com Git - openhab-addons.git/blob
b9015adbe6762f03a340d515f99e77eadf9b9dd7
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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         final Configuration miioConfig = this.miioConfig;
96         if (miioConfig != null) {
97             try {
98                 Dictionary<String, @Nullable Object> properties = miioConfig.getProperties();
99                 String cloudDiscoveryModeConfig;
100                 if (properties == null) {
101                     cloudDiscoveryModeConfig = DISABLED;
102                 } else {
103                     cloudDiscoveryModeConfig = (String) properties.get("cloudDiscoveryMode");
104                     if (cloudDiscoveryModeConfig == null) {
105                         cloudDiscoveryModeConfig = DISABLED;
106                     } else {
107                         cloudDiscoveryModeConfig = cloudDiscoveryModeConfig.toLowerCase();
108                     }
109                 }
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());
113             }
114         }
115         return DISABLED;
116     }
117
118     @Override
119     public Set<ThingTypeUID> getSupportedThingTypes() {
120         return SUPPORTED_THING_TYPES_UIDS;
121     }
122
123     @Override
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,
129                     TimeUnit.SECONDS);
130         }
131     }
132
133     @Override
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;
140         }
141     }
142
143     @Override
144     protected void deactivate() {
145         stopReceiverThreat();
146         final DatagramSocket clientSocket = this.clientSocket;
147         if (clientSocket != null) {
148             clientSocket.close();
149         }
150         this.clientSocket = null;
151         super.deactivate();
152     }
153
154     @Override
155     protected void startScan() {
156         String cloudDiscoveryMode = getCloudDiscoveryMode();
157         logger.debug("Start Xiaomi Mi IO discovery with cloudDiscoveryMode: {}", cloudDiscoveryMode);
158         if (!cloudDiscoveryMode.contentEquals(DISABLED)) {
159             cloudDiscovery();
160         }
161         final DatagramSocket clientSocket = getSocket();
162         if (clientSocket != null) {
163             logger.debug("Discovery using socket on port {}", clientSocket.getLocalPort());
164             discover();
165         } else {
166             logger.debug("Discovery not started. Client DatagramSocket null");
167         }
168     }
169
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);
179         }
180     }
181
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);
192                     }
193                 }
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))
199                             + ")";
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);
206                 } else {
207                     logger.debug("Discovered from cloud, but ignored because not online: {} {}", id, device);
208                 }
209             }
210         }
211     }
212
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)) + ")";
220         String model = "";
221         String country = "";
222         String parent = "";
223         boolean isOnline = false;
224         if (ip.equals(cloudDevices.get(id))) {
225             logger.debug("Skipped adding local found {}. Already discovered by cloud.", label);
226             return;
227         }
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();
239             }
240         }
241         submitDiscovery(ip, token, id, label, model, country, isOnline, parent);
242     }
243
244     private void submitDiscovery(String ip, String token, String id, String label, String model, String country,
245             boolean isOnline, String parent) {
246         ThingUID uid;
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(".", "_"));
250         } else {
251             uid = new ThingUID(THING_TYPE_MIIO, Utils.getHexId(id).replace(".", "_"));
252         }
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);
257             logger.debug(
258                     "No token discovered for device {}. For options how to get the token, check the binding readme.",
259                     id);
260             dr = dr.withRepresentationProperty(PROPERTY_DID).withLabel(label);
261         } else {
262             logger.debug("Discovered Mi Device {} ({}) at {} as {} with token {}", id, Utils.getHexId(id), ip, uid,
263                     token);
264             dr = dr.withProperty(PROPERTY_TOKEN, token).withRepresentationProperty(PROPERTY_DID)
265                     .withLabel(label + " with token");
266         }
267         if (!model.isEmpty()) {
268             dr = dr.withProperty(PROPERTY_MODEL, model);
269         }
270         if (!country.isEmpty() && isOnline) {
271             dr = dr.withProperty(PROPERTY_CLOUDSERVER, country);
272         }
273         thingDiscovered(dr.build());
274     }
275
276     synchronized @Nullable DatagramSocket getSocket() {
277         DatagramSocket clientSocket = this.clientSocket;
278         if (clientSocket != null && clientSocket.isBound()) {
279             return clientSocket;
280         }
281         try {
282             logger.debug("Getting new socket for discovery");
283             clientSocket = new DatagramSocket();
284             clientSocket.setReuseAddress(true);
285             clientSocket.setBroadcast(true);
286             this.clientSocket = clientSocket;
287             return clientSocket;
288         } catch (SocketException | SecurityException e) {
289             logger.debug("Error getting socket for discovery: {}", e.getMessage());
290         }
291         return null;
292     }
293
294     private void closeSocket() {
295         final @Nullable DatagramSocket clientSocket = this.clientSocket;
296         if (clientSocket != null) {
297             clientSocket.close();
298         } else {
299             return;
300         }
301         this.clientSocket = null;
302     }
303
304     private void sendDiscoveryRequest(String ipAddress) {
305         final @Nullable DatagramSocket socket = getSocket();
306         if (socket != null) {
307             try {
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);
315                 }
316             } catch (IOException e) {
317                 logger.trace("Discovery on {} error: {}", ipAddress, e.getMessage());
318             }
319         }
320     }
321
322     /**
323      * starts the {@link ReceiverThread} thread
324      */
325     private synchronized void startReceiverThreat() {
326         final Thread srt = socketReceiveThread;
327         if (srt != null) {
328             if (srt.isAlive() && !srt.isInterrupted()) {
329                 return;
330             }
331         }
332         stopReceiverThreat();
333         Thread socketReceiveThread = new ReceiverThread();
334         socketReceiveThread.start();
335         this.socketReceiveThread = socketReceiveThread;
336     }
337
338     /**
339      * Stops the {@link ReceiverThread} thread
340      */
341     private synchronized void stopReceiverThreat() {
342         final Thread socketReceiveThread = this.socketReceiveThread;
343         if (socketReceiveThread != null) {
344             socketReceiveThread.interrupt();
345             this.socketReceiveThread = null;
346         }
347         closeSocket();
348     }
349
350     /**
351      * The thread, which waits for data and submits the unique results addresses to the discovery results
352      *
353      */
354     private class ReceiverThread extends Thread {
355         @Override
356         public void run() {
357             DatagramSocket socket = getSocket();
358             if (socket != null) {
359                 logger.debug("Starting discovery receiver thread for socket on port {}", socket.getLocalPort());
360                 receiveData(socket);
361             }
362         }
363
364         /**
365          * This method waits for data and submits the unique results addresses to the discovery results
366          *
367          * @param socket - The multicast socket to (re)use
368          */
369         private void receiveData(DatagramSocket socket) {
370             DatagramPacket receivePacket = new DatagramPacket(new byte[BUFFER_LENGTH], BUFFER_LENGTH);
371             try {
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());
378
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());
385                     }
386                     if (!responseIps.contains(hostAddress)) {
387                         scheduler.schedule(() -> {
388                             try {
389                                 discovered(hostAddress, messageBuf);
390                             } catch (Exception e) {
391                                 logger.debug("Error submitting discovered Mi IO device at {}", hostAddress, e);
392                             }
393                         }, 0, TimeUnit.SECONDS);
394                     }
395                     responseIps.add(hostAddress);
396                 }
397             } catch (SocketException e) {
398                 logger.debug("Receiver thread received SocketException: {}", e.getMessage());
399             } catch (IOException e) {
400                 logger.trace("Receiver thread was interrupted");
401             }
402             logger.debug("Receiver thread ended");
403         }
404     }
405 }