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.lifx.internal;
15 import static org.openhab.binding.lifx.internal.LifxBindingConstants.*;
16 import static org.openhab.binding.lifx.internal.util.LifxMessageUtil.randomSourceId;
17 import static org.openhab.binding.lifx.internal.util.LifxSelectorUtil.*;
19 import java.io.IOException;
20 import java.net.InetSocketAddress;
21 import java.nio.channels.SelectionKey;
22 import java.nio.channels.Selector;
23 import java.util.HashMap;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.function.Supplier;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.lifx.internal.dto.GetLabelRequest;
32 import org.openhab.binding.lifx.internal.dto.GetServiceRequest;
33 import org.openhab.binding.lifx.internal.dto.GetVersionRequest;
34 import org.openhab.binding.lifx.internal.dto.Packet;
35 import org.openhab.binding.lifx.internal.dto.StateLabelResponse;
36 import org.openhab.binding.lifx.internal.dto.StateServiceResponse;
37 import org.openhab.binding.lifx.internal.dto.StateVersionResponse;
38 import org.openhab.binding.lifx.internal.fields.MACAddress;
39 import org.openhab.binding.lifx.internal.util.LifxSelectorUtil;
40 import org.openhab.core.config.discovery.AbstractDiscoveryService;
41 import org.openhab.core.config.discovery.DiscoveryResult;
42 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
43 import org.openhab.core.config.discovery.DiscoveryService;
44 import org.openhab.core.thing.ThingUID;
45 import org.osgi.service.component.annotations.Activate;
46 import org.osgi.service.component.annotations.Component;
47 import org.osgi.service.component.annotations.Deactivate;
48 import org.osgi.service.component.annotations.Modified;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
53 * The {@link LifxLightDiscovery} provides support for auto-discovery of LIFX
56 * @author Dennis Nobel - Initial contribution
57 * @author Karel Goderis - Rewrite for Firmware V2, and remove dependency on external libraries
58 * @author Wouter Born - Discover light labels, improve locking, optimize packet handling
60 @Component(service = DiscoveryService.class, configurationPid = "discovery.lifx")
62 public class LifxLightDiscovery extends AbstractDiscoveryService {
64 private static final String LOG_ID = "Discovery";
65 private static final long REFRESH_INTERVAL = TimeUnit.MINUTES.toSeconds(1);
66 private static final long SELECTOR_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
68 private final Logger logger = LoggerFactory.getLogger(LifxLightDiscovery.class);
70 private final Map<MACAddress, DiscoveredLight> discoveredLights = new HashMap<>();
71 private final long sourceId = randomSourceId();
72 private final Supplier<Integer> sequenceNumberSupplier = new LifxSequenceNumberSupplier();
74 private @Nullable Selector selector;
75 private @Nullable SelectionKey broadcastKey;
77 private @Nullable ScheduledFuture<?> discoveryJob;
78 private @Nullable ScheduledFuture<?> networkJob;
80 private boolean isScanning = false;
82 private class DiscoveredLight {
84 private MACAddress macAddress;
85 private InetSocketAddress socketAddress;
87 private @Nullable String label;
88 private @Nullable LifxProduct product;
89 private long productVersion;
90 private boolean supportedProduct = true;
91 private LifxSelectorContext selectorContext;
93 private long lastRequestTimeMillis;
95 public DiscoveredLight(Selector lightSelector, MACAddress macAddress, InetSocketAddress socketAddress,
96 String logId, @Nullable SelectionKey unicastKey) {
97 this.macAddress = macAddress;
99 this.socketAddress = socketAddress;
100 this.selectorContext = new LifxSelectorContext(lightSelector, sourceId, sequenceNumberSupplier, logId,
101 socketAddress, macAddress, broadcastKey, unicastKey);
104 public boolean isDataComplete() {
105 return label != null && product != null;
108 public void cancelUnicastKey() {
109 SelectionKey unicastKey = selectorContext.getUnicastKey();
110 if (unicastKey != null) {
111 cancelKey(unicastKey, selectorContext.getLogId());
116 public LifxLightDiscovery() throws IllegalArgumentException {
117 super(LifxBindingConstants.SUPPORTED_THING_TYPES, 1, true);
122 protected void activate(@Nullable Map<String, Object> configProperties) {
123 super.activate(configProperties);
128 protected void modified(@Nullable Map<String, Object> configProperties) {
129 super.modified(configProperties);
134 protected void deactivate() {
139 protected void startBackgroundDiscovery() {
140 logger.debug("Starting the LIFX device background discovery");
142 ScheduledFuture<?> localDiscoveryJob = discoveryJob;
143 if (localDiscoveryJob == null || localDiscoveryJob.isCancelled()) {
144 discoveryJob = scheduler.scheduleWithFixedDelay(this::doScan, 0, REFRESH_INTERVAL, TimeUnit.SECONDS);
149 protected void stopBackgroundDiscovery() {
150 logger.debug("Stopping LIFX device background discovery");
152 ScheduledFuture<?> localDiscoveryJob = discoveryJob;
153 if (localDiscoveryJob != null && !localDiscoveryJob.isCancelled()) {
154 localDiscoveryJob.cancel(true);
158 ScheduledFuture<?> localNetworkJob = networkJob;
159 if (localNetworkJob != null && !localNetworkJob.isCancelled()) {
160 localNetworkJob.cancel(true);
166 protected void startScan() {
171 protected synchronized void stopScan() {
173 removeOlderResults(getTimestampOfLastScan());
176 protected void doScan() {
180 if (selector != null) {
181 closeSelector(selector, LOG_ID);
184 logger.debug("The LIFX discovery service will use '{}' as source identifier",
185 Long.toString(sourceId, 16));
187 Selector localSelector = Selector.open();
188 selector = localSelector;
190 broadcastKey = openBroadcastChannel(localSelector, LOG_ID, BROADCAST_PORT);
191 networkJob = scheduler.schedule(this::receiveAndHandlePackets, 0, TimeUnit.MILLISECONDS);
193 LifxSelectorContext selectorContext = new LifxSelectorContext(localSelector, sourceId,
194 sequenceNumberSupplier, LOG_ID, broadcastKey);
195 broadcastPacket(selectorContext, new GetServiceRequest());
197 logger.info("A discovery scan for LIFX lights is already underway");
199 } catch (IOException e) {
200 logger.debug("{} while discovering LIFX lights : {}", e.getClass().getSimpleName(), e.getMessage());
205 public void receiveAndHandlePackets() {
206 Selector localSelector = selector;
209 if (localSelector == null || !localSelector.isOpen()) {
210 logger.debug("Unable to receive and handle packets with null or closed selector");
214 discoveredLights.clear();
215 logger.trace("Entering read loop");
216 long startStamp = System.currentTimeMillis();
218 while (System.currentTimeMillis() - startStamp < SELECTOR_TIMEOUT) {
219 int lightCount = discoveredLights.size();
220 long selectStamp = System.currentTimeMillis();
222 LifxSelectorUtil.receiveAndHandlePackets(localSelector, LOG_ID,
223 (packet, address) -> handlePacket(packet, address));
224 requestAdditionalLightData();
226 boolean discoveredNewLights = lightCount < discoveredLights.size();
227 if (!discoveredNewLights) {
228 boolean preventBusyWaiting = System.currentTimeMillis() - selectStamp < PACKET_INTERVAL;
229 if (preventBusyWaiting) {
230 Thread.sleep(PACKET_INTERVAL);
234 logger.trace("Exited read loop");
235 } catch (Exception e) {
236 logger.debug("{} while receiving and handling discovery packets: {}", e.getClass().getSimpleName(),
239 LifxSelectorUtil.closeSelector(localSelector, LOG_ID);
245 private void requestAdditionalLightData() {
246 // Iterate through the discovered lights that have to be set up, and the packets that have to be sent
247 // Workaround to avoid a ConcurrentModifictionException on the selector.SelectedKeys() Set
248 for (DiscoveredLight light : discoveredLights.values()) {
249 boolean waitingForLightResponse = System.currentTimeMillis() - light.lastRequestTimeMillis < 200;
251 if (light.supportedProduct && !light.isDataComplete() && !waitingForLightResponse) {
252 if (light.product == null) {
253 sendPacket(light.selectorContext, new GetVersionRequest());
255 if (light.label == null) {
256 sendPacket(light.selectorContext, new GetLabelRequest());
258 light.lastRequestTimeMillis = System.currentTimeMillis();
263 private void handlePacket(Packet packet, InetSocketAddress address) {
264 logger.trace("Discovery : Packet type '{}' received from '{}' for '{}' with sequence '{}' and source '{}'",
265 new Object[] { packet.getClass().getSimpleName(), address.toString(), packet.getTarget().getHex(),
266 packet.getSequence(), Long.toString(packet.getSource(), 16) });
268 if (packet.getSource() == sourceId || packet.getSource() == 0) {
269 MACAddress macAddress = packet.getTarget();
270 DiscoveredLight light = discoveredLights.get(macAddress);
272 if (packet instanceof StateServiceResponse response) {
273 int port = (int) response.getPort();
276 InetSocketAddress socketAddress = new InetSocketAddress(address.getAddress(), port);
277 if (light == null || (!socketAddress.equals(light.socketAddress))) {
279 light.cancelUnicastKey();
282 Selector lightSelector = selector;
283 if (lightSelector != null) {
284 String logId = getLogId(macAddress, socketAddress);
285 light = new DiscoveredLight(lightSelector, macAddress, socketAddress, logId,
286 openUnicastChannel(lightSelector, logId, socketAddress));
287 discoveredLights.put(macAddress, light);
290 } catch (Exception e) {
291 logger.warn("{} while connecting to IP address: {}", e.getClass().getSimpleName(),
296 } else if (light != null) {
297 if (packet instanceof StateLabelResponse response) {
298 light.label = response.getLabel().trim();
299 } else if (packet instanceof StateVersionResponse response) {
301 LifxProduct product = LifxProduct.getProductFromProductID(response.getProduct());
302 light.product = product;
303 light.productVersion = response.getVersion();
304 light.supportedProduct = product.isLight();
305 } catch (IllegalArgumentException e) {
306 logger.debug("Discovered an unsupported light ({}): {}", light.macAddress.getAsLabel(),
308 light.supportedProduct = false;
313 if (light != null && light.supportedProduct && light.isDataComplete()) {
315 thingDiscovered(createDiscoveryResult(light));
316 } catch (IllegalArgumentException e) {
317 logger.trace("{} while creating discovery result of light ({})", e.getClass().getSimpleName(),
324 private DiscoveryResult createDiscoveryResult(DiscoveredLight light) throws IllegalArgumentException {
325 LifxProduct product = light.product;
326 if (product == null) {
327 throw new IllegalArgumentException("Product of discovered light is null");
330 String macAsLabel = light.macAddress.getAsLabel();
331 ThingUID thingUID = new ThingUID(product.getThingTypeUID(), macAsLabel);
333 String label = light.label;
334 if (label == null || label.isBlank()) {
335 label = product.getName();
338 logger.trace("Discovered a LIFX light: {}", label);
340 DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(thingUID);
341 builder.withRepresentationProperty(LifxBindingConstants.PROPERTY_MAC_ADDRESS);
342 builder.withLabel(label);
344 builder.withProperty(LifxBindingConstants.CONFIG_PROPERTY_DEVICE_ID, macAsLabel);
345 builder.withProperty(LifxBindingConstants.PROPERTY_MAC_ADDRESS, macAsLabel);
346 builder.withProperty(LifxBindingConstants.PROPERTY_PRODUCT_ID, product.getID());
347 builder.withProperty(LifxBindingConstants.PROPERTY_PRODUCT_NAME, product.getName());
348 builder.withProperty(LifxBindingConstants.PROPERTY_PRODUCT_VERSION, light.productVersion);
349 builder.withProperty(LifxBindingConstants.PROPERTY_VENDOR_ID, product.getVendor().getID());
350 builder.withProperty(LifxBindingConstants.PROPERTY_VENDOR_NAME, product.getVendor().getName());
352 return builder.build();