]> git.basschouten.com Git - openhab-addons.git/blob
9eb8ddc25149a76ffd52ed4bea158872cc41bdff
[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.milight.internal.discovery;
14
15 import java.io.IOException;
16 import java.net.DatagramPacket;
17 import java.net.DatagramSocket;
18 import java.net.InetAddress;
19 import java.net.InetSocketAddress;
20 import java.net.InterfaceAddress;
21 import java.net.NetworkInterface;
22 import java.net.SocketException;
23 import java.net.UnknownHostException;
24 import java.util.Enumeration;
25 import java.util.Map;
26 import java.util.TreeMap;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.Semaphore;
29 import java.util.concurrent.TimeUnit;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.milight.internal.MilightBindingConstants;
34 import org.openhab.binding.milight.internal.handler.BridgeHandlerConfig;
35 import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager;
36 import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager.ISessionState;
37 import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager.SessionState;
38 import org.openhab.core.config.discovery.AbstractDiscoveryService;
39 import org.openhab.core.config.discovery.DiscoveryResult;
40 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
41 import org.openhab.core.config.discovery.DiscoveryService;
42 import org.openhab.core.thing.ThingUID;
43 import org.osgi.service.component.annotations.Component;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
46
47 /**
48  * Milight bridges v3/v4/v5 and v6 can be discovered by sending specially formated UDP packets.
49  * This class sends UDP packets on port PORT_DISCOVER up to three times in a row
50  * and listens for the response and will call discoverResult.bridgeDetected() eventually.
51  *
52  * The response of the bridges is unfortunately very generic and is the unmodified response of
53  * any HF-LPB100 wifi chipset. Therefore other devices as the Orvibo Smart Plugs are recognised
54  * as Milight Bridges as well. For v5/v6 there are some additional checks to make sure we are
55  * talking to a Milight.
56  *
57  * @author David Graeff - Initial contribution
58  */
59 @NonNullByDefault
60 @Component(service = DiscoveryService.class, configurationPid = "discovery.milight")
61 public class MilightBridgeDiscovery extends AbstractDiscoveryService implements Runnable {
62     private final Logger logger = LoggerFactory.getLogger(MilightBridgeDiscovery.class);
63
64     ///// Static configuration
65     private static final boolean ENABLE_V3 = true;
66     private static final boolean ENABLE_V6 = true;
67
68     private @Nullable ScheduledFuture<?> backgroundFuture;
69
70     ///// Network
71     private final int receivePort;
72     private final DatagramPacket discoverPacketV3;
73     private final DatagramPacket discoverPacketV6;
74     private boolean willbeclosed = false;
75     @NonNullByDefault({})
76     private DatagramSocket datagramSocket;
77     private final byte[] buffer = new byte[1024];
78     private final DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
79
80     ///// Result and resend
81     private int resendCounter = 0;
82     private @Nullable ScheduledFuture<?> resendTimer;
83     private final int resendTimeoutInMillis;
84     private final int resendAttempts;
85
86     public MilightBridgeDiscovery() throws IllegalArgumentException, UnknownHostException {
87         super(MilightBindingConstants.BRIDGE_THING_TYPES_UIDS, 2, true);
88         this.resendAttempts = 2000 / 200;
89         this.resendTimeoutInMillis = 200;
90         this.receivePort = MilightBindingConstants.PORT_DISCOVER;
91         discoverPacketV3 = new DatagramPacket(MilightBindingConstants.DISCOVER_MSG_V3,
92                 MilightBindingConstants.DISCOVER_MSG_V3.length);
93         discoverPacketV6 = new DatagramPacket(MilightBindingConstants.DISCOVER_MSG_V6,
94                 MilightBindingConstants.DISCOVER_MSG_V6.length);
95
96         startDiscoveryService();
97     }
98
99     @Override
100     protected void startBackgroundDiscovery() {
101         if (backgroundFuture != null) {
102             return;
103         }
104
105         backgroundFuture = scheduler.scheduleWithFixedDelay(this::startDiscoveryService, 50, 60000 * 30,
106                 TimeUnit.MILLISECONDS);
107     }
108
109     @Override
110     protected void stopBackgroundDiscovery() {
111         stopScan();
112         final ScheduledFuture<?> future = backgroundFuture;
113         if (future != null) {
114             future.cancel(false);
115             this.backgroundFuture = null;
116         }
117         stop();
118     }
119
120     public void bridgeDetected(InetAddress addr, String id, int version) {
121         ThingUID thingUID = new ThingUID(version == 6 ? MilightBindingConstants.BRIDGEV6_THING_TYPE
122                 : MilightBindingConstants.BRIDGEV3_THING_TYPE, id);
123
124         Map<String, Object> properties = new TreeMap<>();
125         properties.put(BridgeHandlerConfig.CONFIG_BRIDGE_ID, id);
126         properties.put(BridgeHandlerConfig.CONFIG_HOST_NAME, addr.getHostAddress());
127
128         String label = "Bridge " + id;
129
130         DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withLabel(label)
131                 .withProperties(properties).build();
132         thingDiscovered(discoveryResult);
133     }
134
135     @Override
136     protected void startScan() {
137         startDiscoveryService();
138     }
139
140     @Override
141     protected synchronized void stopScan() {
142         stop();
143         super.stopScan();
144     }
145
146     /**
147      * Used by the scheduler to resend discover messages. Stops after a configured amount of attempts.
148      */
149     private class SendDiscoverRunnable implements Runnable {
150         @Override
151         public void run() {
152             // Stop after a certain amount of attempts
153             if (++resendCounter > resendAttempts) {
154                 stop();
155                 return;
156             }
157
158             Enumeration<NetworkInterface> e;
159             try {
160                 e = NetworkInterface.getNetworkInterfaces();
161             } catch (SocketException e1) {
162                 logger.error("Could not enumerate network interfaces for sending the discover packet!");
163                 stop();
164                 return;
165             }
166             while (e.hasMoreElements()) {
167                 NetworkInterface networkInterface = e.nextElement();
168                 for (InterfaceAddress address : networkInterface.getInterfaceAddresses()) {
169                     InetAddress broadcast = address.getBroadcast();
170                     if (broadcast != null && !address.getAddress().isLoopbackAddress()) {
171                         sendDiscover(broadcast);
172                     }
173                 }
174             }
175         }
176
177         private void sendDiscover(InetAddress destIP) {
178             if (ENABLE_V3) {
179                 discoverPacketV3.setAddress(destIP);
180                 discoverPacketV3.setPort(MilightBindingConstants.PORT_DISCOVER);
181                 try {
182                     datagramSocket.send(discoverPacketV3);
183                 } catch (IOException e) {
184                     logger.error("Sending a V3 discovery packet to {} failed. {}", destIP.getHostAddress(),
185                             e.getLocalizedMessage());
186                 }
187             }
188
189             if (ENABLE_V6) {
190                 discoverPacketV6.setAddress(destIP);
191                 discoverPacketV6.setPort(MilightBindingConstants.PORT_DISCOVER);
192                 try {
193                     datagramSocket.send(discoverPacketV6);
194                 } catch (IOException e) {
195                     logger.error("Sending a V6 discovery packet to {} failed. {}", destIP.getHostAddress(),
196                             e.getLocalizedMessage());
197                 }
198             }
199         }
200     }
201
202     /**
203      * This will not stop the discovery thread (like dispose()), so discovery
204      * packet responses can still be received, but will stop
205      * re-sending discovery packets. Call sendDiscover() to restart sending
206      * discovery packets.
207      */
208     public void stop() {
209         if (resendTimer != null) {
210             resendTimer.cancel(false);
211             resendTimer = null;
212         }
213
214         if (willbeclosed) {
215             return;
216         }
217         willbeclosed = true;
218         datagramSocket.close();
219     }
220
221     /**
222      * Send a discover message and resends the message until either a valid response
223      * is received or the resend counter reaches the maximum attempts.
224      *
225      * @param scheduler The scheduler is used for resending.
226      * @throws SocketException
227      */
228     public void startDiscoveryService() {
229         // Do nothing if there is already a discovery running
230         if (resendTimer != null) {
231             return;
232         }
233
234         willbeclosed = false;
235         try {
236             datagramSocket = new DatagramSocket(null);
237             datagramSocket.setBroadcast(true);
238             datagramSocket.setReuseAddress(true);
239             datagramSocket.bind(null);
240         } catch (SocketException e) {
241             logger.error("Opening a socket for the milight discovery service failed. {}", e.getLocalizedMessage());
242             return;
243         }
244         resendCounter = 0;
245         resendTimer = scheduler.scheduleWithFixedDelay(new SendDiscoverRunnable(), 0, resendTimeoutInMillis,
246                 TimeUnit.MILLISECONDS);
247         scheduler.execute(this);
248     }
249
250     @Override
251     public void run() {
252         try {
253             while (!willbeclosed) {
254                 packet.setLength(buffer.length);
255                 datagramSocket.receive(packet);
256                 // We expect packets with a format like this: 10.1.1.27,ACCF23F57AD4,HF-LPB100
257                 String[] msg = new String(buffer, 0, packet.getLength()).split(",");
258
259                 if (msg.length != 2 && msg.length != 3) {
260                     // That data packet does not belong to a Milight bridge. Just ignore it.
261                     continue;
262                 }
263
264                 // First argument is the IP
265                 try {
266                     InetAddress.getByName(msg[0]);
267                 } catch (UnknownHostException ignored) {
268                     // That data packet does not belong to a Milight bridge, we expect an IP address as first argument.
269                     // Just ignore it.
270                     continue;
271                 }
272
273                 // Second argument is the MAC address
274                 if (msg[1].length() != 12) {
275                     // That data packet does not belong to a Milight bridge, we expect a MAC address as second argument.
276                     // Just ignore it.
277                     continue;
278                 }
279
280                 InetAddress addressOfBridge = ((InetSocketAddress) packet.getSocketAddress()).getAddress();
281                 if (ENABLE_V6 && msg.length == 3) {
282                     if (!(msg[2].length() == 0 || "HF-LPB100".equals(msg[2]))) {
283                         logger.trace("Unexpected data. We expected a HF-LPB100 or empty identifier {}", msg[2]);
284                         continue;
285                     }
286                     if (!checkForV6Bridge(addressOfBridge, msg[1])) {
287                         logger.trace("The device at IP {} does not seem to be a V6 Milight bridge", msg[0]);
288                         continue;
289                     }
290                     bridgeDetected(addressOfBridge, msg[1], 6);
291                 } else if (ENABLE_V3 && msg.length == 2) {
292                     bridgeDetected(addressOfBridge, msg[1], 3);
293                 } else {
294                     logger.debug("Unexpected data. Expected Milight bridge message");
295                 }
296             }
297         } catch (IOException e) {
298             if (willbeclosed) {
299                 return;
300             }
301             logger.warn("{}", e.getLocalizedMessage());
302         } catch (InterruptedException ignore) {
303             // Ignore this exception, the thread is finished now anyway
304         }
305     }
306
307     /**
308      * We use the {@see MilightV6SessionManager} to establish a full session to the bridge. If we reach
309      * the SESSION_VALID state within 1.3s, we can safely assume it is a V6 Milight bridge.
310      *
311      * @param addressOfBridge IP Address of the bridge
312      * @return
313      * @throws InterruptedException If waiting for the session is interrupted we throw this exception
314      */
315     private boolean checkForV6Bridge(InetAddress addressOfBridge, String bridgeID) throws InterruptedException {
316         Semaphore s = new Semaphore(0);
317         ISessionState sessionState = (SessionState state, InetAddress address) -> {
318             if (state == SessionState.SESSION_VALID) {
319                 s.release();
320             }
321             logger.debug("STATE CHANGE: {}", state);
322         };
323
324         try (MilightV6SessionManager session = new MilightV6SessionManager(bridgeID, sessionState, addressOfBridge,
325                 MilightBindingConstants.PORT_VER6, MilightV6SessionManager.TIMEOUT_MS, new byte[] { 0, 0 })) {
326             session.start();
327             boolean success = s.tryAcquire(1, 1300, TimeUnit.MILLISECONDS);
328             return success;
329         } catch (IOException e) {
330             logger.debug("checkForV6Bridge failed", e);
331         }
332         return false;
333     }
334 }