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.milight.internal.discovery;
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;
26 import java.util.TreeMap;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.Semaphore;
29 import java.util.concurrent.TimeUnit;
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;
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.
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.
57 * @author David Graeff - Initial contribution
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);
64 ///// Static configuration
65 private static final boolean ENABLE_V3 = true;
66 private static final boolean ENABLE_V6 = true;
68 private @Nullable ScheduledFuture<?> backgroundFuture;
71 private final int receivePort;
72 private final DatagramPacket discoverPacketV3;
73 private final DatagramPacket discoverPacketV6;
74 private boolean willbeclosed = false;
76 private DatagramSocket datagramSocket;
77 private final byte[] buffer = new byte[1024];
78 private final DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
80 ///// Result and resend
81 private int resendCounter = 0;
82 private @Nullable ScheduledFuture<?> resendTimer;
83 private final int resendTimeoutInMillis;
84 private final int resendAttempts;
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);
96 startDiscoveryService();
100 protected void startBackgroundDiscovery() {
101 if (backgroundFuture != null) {
105 backgroundFuture = scheduler.scheduleWithFixedDelay(this::startDiscoveryService, 50, 60000 * 30,
106 TimeUnit.MILLISECONDS);
110 protected void stopBackgroundDiscovery() {
112 final ScheduledFuture<?> future = backgroundFuture;
113 if (future != null) {
114 future.cancel(false);
115 this.backgroundFuture = null;
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);
124 Map<String, Object> properties = new TreeMap<>();
125 properties.put(BridgeHandlerConfig.CONFIG_BRIDGE_ID, id);
126 properties.put(BridgeHandlerConfig.CONFIG_HOST_NAME, addr.getHostAddress());
128 String label = "Bridge " + id;
130 DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withLabel(label)
131 .withProperties(properties).build();
132 thingDiscovered(discoveryResult);
136 protected void startScan() {
137 startDiscoveryService();
141 protected synchronized void stopScan() {
147 * Used by the scheduler to resend discover messages. Stops after a configured amount of attempts.
149 private class SendDiscoverRunnable implements Runnable {
152 // Stop after a certain amount of attempts
153 if (++resendCounter > resendAttempts) {
158 Enumeration<NetworkInterface> e;
160 e = NetworkInterface.getNetworkInterfaces();
161 } catch (SocketException e1) {
162 logger.error("Could not enumerate network interfaces for sending the discover packet!");
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);
177 private void sendDiscover(InetAddress destIP) {
179 discoverPacketV3.setAddress(destIP);
180 discoverPacketV3.setPort(MilightBindingConstants.PORT_DISCOVER);
182 datagramSocket.send(discoverPacketV3);
183 } catch (IOException e) {
184 logger.error("Sending a V3 discovery packet to {} failed. {}", destIP.getHostAddress(),
185 e.getLocalizedMessage());
190 discoverPacketV6.setAddress(destIP);
191 discoverPacketV6.setPort(MilightBindingConstants.PORT_DISCOVER);
193 datagramSocket.send(discoverPacketV6);
194 } catch (IOException e) {
195 logger.error("Sending a V6 discovery packet to {} failed. {}", destIP.getHostAddress(),
196 e.getLocalizedMessage());
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
209 if (resendTimer != null) {
210 resendTimer.cancel(false);
218 datagramSocket.close();
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.
225 * @param scheduler The scheduler is used for resending.
226 * @throws SocketException
228 public void startDiscoveryService() {
229 // Do nothing if there is already a discovery running
230 if (resendTimer != null) {
234 willbeclosed = false;
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());
245 resendTimer = scheduler.scheduleWithFixedDelay(new SendDiscoverRunnable(), 0, resendTimeoutInMillis,
246 TimeUnit.MILLISECONDS);
247 scheduler.execute(this);
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(",");
259 if (msg.length != 2 && msg.length != 3) {
260 // That data packet does not belong to a Milight bridge. Just ignore it.
264 // First argument is the IP
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.
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.
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]);
286 if (!checkForV6Bridge(addressOfBridge, msg[1])) {
287 logger.trace("The device at IP {} does not seem to be a V6 Milight bridge", msg[0]);
290 bridgeDetected(addressOfBridge, msg[1], 6);
291 } else if (ENABLE_V3 && msg.length == 2) {
292 bridgeDetected(addressOfBridge, msg[1], 3);
294 logger.debug("Unexpected data. Expected Milight bridge message");
297 } catch (IOException e) {
301 logger.warn("{}", e.getLocalizedMessage());
302 } catch (InterruptedException ignore) {
303 // Ignore this exception, the thread is finished now anyway
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.
311 * @param addressOfBridge IP Address of the bridge
313 * @throws InterruptedException If waiting for the session is interrupted we throw this exception
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) {
321 logger.debug("STATE CHANGE: {}", state);
324 try (MilightV6SessionManager session = new MilightV6SessionManager(bridgeID, sessionState, addressOfBridge,
325 MilightBindingConstants.PORT_VER6, MilightV6SessionManager.TIMEOUT_MS, new byte[] { 0, 0 })) {
327 boolean success = s.tryAcquire(1, 1300, TimeUnit.MILLISECONDS);
329 } catch (IOException e) {
330 logger.debug("checkForV6Bridge failed", e);