]> git.basschouten.com Git - openhab-addons.git/blob
0703e32f62d19742ca4e2c0c8e5b19c92fa7917d
[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     public void startDiscoveryService() {
226         // Do nothing if there is already a discovery running
227         if (resendTimer != null) {
228             return;
229         }
230
231         willbeclosed = false;
232         try {
233             datagramSocket = new DatagramSocket(null);
234             datagramSocket.setBroadcast(true);
235             datagramSocket.setReuseAddress(true);
236             datagramSocket.bind(null);
237         } catch (SocketException e) {
238             logger.error("Opening a socket for the milight discovery service failed. {}", e.getLocalizedMessage());
239             return;
240         }
241         resendCounter = 0;
242         resendTimer = scheduler.scheduleWithFixedDelay(new SendDiscoverRunnable(), 0, resendTimeoutInMillis,
243                 TimeUnit.MILLISECONDS);
244         scheduler.execute(this);
245     }
246
247     @Override
248     public void run() {
249         try {
250             while (!willbeclosed) {
251                 packet.setLength(buffer.length);
252                 datagramSocket.receive(packet);
253                 // We expect packets with a format like this: 10.1.1.27,ACCF23F57AD4,HF-LPB100
254                 String[] msg = new String(buffer, 0, packet.getLength()).split(",");
255
256                 if (msg.length != 2 && msg.length != 3) {
257                     // That data packet does not belong to a Milight bridge. Just ignore it.
258                     continue;
259                 }
260
261                 // First argument is the IP
262                 try {
263                     InetAddress.getByName(msg[0]);
264                 } catch (UnknownHostException ignored) {
265                     // That data packet does not belong to a Milight bridge, we expect an IP address as first argument.
266                     // Just ignore it.
267                     continue;
268                 }
269
270                 // Second argument is the MAC address
271                 if (msg[1].length() != 12) {
272                     // That data packet does not belong to a Milight bridge, we expect a MAC address as second argument.
273                     // Just ignore it.
274                     continue;
275                 }
276
277                 InetAddress addressOfBridge = ((InetSocketAddress) packet.getSocketAddress()).getAddress();
278                 if (ENABLE_V6 && msg.length == 3) {
279                     if (!(msg[2].length() == 0 || "HF-LPB100".equals(msg[2]))) {
280                         logger.trace("Unexpected data. We expected a HF-LPB100 or empty identifier {}", msg[2]);
281                         continue;
282                     }
283                     if (!checkForV6Bridge(addressOfBridge, msg[1])) {
284                         logger.trace("The device at IP {} does not seem to be a V6 Milight bridge", msg[0]);
285                         continue;
286                     }
287                     bridgeDetected(addressOfBridge, msg[1], 6);
288                 } else if (ENABLE_V3 && msg.length == 2) {
289                     bridgeDetected(addressOfBridge, msg[1], 3);
290                 } else {
291                     logger.debug("Unexpected data. Expected Milight bridge message");
292                 }
293             }
294         } catch (IOException e) {
295             if (willbeclosed) {
296                 return;
297             }
298             logger.warn("{}", e.getLocalizedMessage());
299         } catch (InterruptedException ignore) {
300             // Ignore this exception, the thread is finished now anyway
301         }
302     }
303
304     /**
305      * We use the {@see MilightV6SessionManager} to establish a full session to the bridge. If we reach
306      * the SESSION_VALID state within 1.3s, we can safely assume it is a V6 Milight bridge.
307      *
308      * @param addressOfBridge IP Address of the bridge
309      * @return
310      * @throws InterruptedException If waiting for the session is interrupted we throw this exception
311      */
312     private boolean checkForV6Bridge(InetAddress addressOfBridge, String bridgeID) throws InterruptedException {
313         Semaphore s = new Semaphore(0);
314         ISessionState sessionState = (SessionState state, InetAddress address) -> {
315             if (state == SessionState.SESSION_VALID) {
316                 s.release();
317             }
318             logger.debug("STATE CHANGE: {}", state);
319         };
320
321         try (MilightV6SessionManager session = new MilightV6SessionManager(bridgeID, sessionState, addressOfBridge,
322                 MilightBindingConstants.PORT_VER6, MilightV6SessionManager.TIMEOUT_MS, new byte[] { 0, 0 })) {
323             session.start();
324             return s.tryAcquire(1, 1300, TimeUnit.MILLISECONDS);
325         } catch (IOException e) {
326             logger.debug("checkForV6Bridge failed", e);
327         }
328         return false;
329     }
330 }