/**
* Copyright (c) 2014-2017 by the respective copyright holders.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*/
package org.eclipse.smarthome.binding.lifx.internal;
import java.io.IOException;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.StandardProtocolFamily;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.DatagramChannel;
import java.nio.channels.SelectableChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang.StringUtils;
import org.eclipse.smarthome.binding.lifx.LifxBindingConstants;
import org.eclipse.smarthome.binding.lifx.internal.fields.MACAddress;
import org.eclipse.smarthome.binding.lifx.internal.fields.Version;
import org.eclipse.smarthome.binding.lifx.internal.protocol.GetHostFirmwareRequest;
import org.eclipse.smarthome.binding.lifx.internal.protocol.GetLabelRequest;
import org.eclipse.smarthome.binding.lifx.internal.protocol.GetServiceRequest;
import org.eclipse.smarthome.binding.lifx.internal.protocol.GetVersionRequest;
import org.eclipse.smarthome.binding.lifx.internal.protocol.GetWifiFirmwareRequest;
import org.eclipse.smarthome.binding.lifx.internal.protocol.Packet;
import org.eclipse.smarthome.binding.lifx.internal.protocol.PacketFactory;
import org.eclipse.smarthome.binding.lifx.internal.protocol.PacketHandler;
import org.eclipse.smarthome.binding.lifx.internal.protocol.Products;
import org.eclipse.smarthome.binding.lifx.internal.protocol.StateHostFirmwareResponse;
import org.eclipse.smarthome.binding.lifx.internal.protocol.StateLabelResponse;
import org.eclipse.smarthome.binding.lifx.internal.protocol.StateServiceResponse;
import org.eclipse.smarthome.binding.lifx.internal.protocol.StateVersionResponse;
import org.eclipse.smarthome.binding.lifx.internal.protocol.StateWifiFirmwareResponse;
import org.eclipse.smarthome.config.discovery.AbstractDiscoveryService;
import org.eclipse.smarthome.config.discovery.DiscoveryResult;
import org.eclipse.smarthome.config.discovery.DiscoveryResultBuilder;
import org.eclipse.smarthome.core.thing.ThingUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link LifxLightDiscovery} provides support for auto-discovery of LIFX
* lights.
*
* @author Dennis Nobel - Initial contribution
* @author Karel Goderis - Rewrite for Firmware V2, and remove dependency on external libraries
* @author Wouter Born - Discover light labels, improve locking, optimize packet handling
*/
public class LifxLightDiscovery extends AbstractDiscoveryService {
private Logger logger = LoggerFactory.getLogger(LifxLightDiscovery.class);
private static final int SERVICE_REQUEST_SEQ_NO = 0;
private static final int VERSION_REQUEST_SEQ_NO = 1;
private static final int LABEL_REQUEST_SEQ_NO = 2;
private static final int HOST_VERSION_REQUEST_SEQ_NO = 3;
private static final int WIFI_VERSION_REQUEST_SEQ_NO = 4;
private List<InetSocketAddress> broadcastAddresses;
private List<InetAddress> interfaceAddresses;
private final int BROADCAST_PORT = 56700;
private static int REFRESH_INTERVAL = 60;
private static int BROADCAST_TIMEOUT = 5000;
private static int SELECTOR_TIMEOUT = 10000;
private int bufferSize = 0;
private Selector selector;
private DatagramChannel broadcastChannel;
private long source;
private boolean isScanning = false;
private ScheduledFuture<?> discoveryJob;
private ScheduledFuture<?> networkJob;
private Map<MACAddress, DiscoveredLight> discoveredLights = new HashMap<MACAddress, DiscoveredLight>();
private class DiscoveredLight {
private MACAddress macAddress;
private InetSocketAddress socketAddress;
private String label;
private Version hostVersion;
private Products products;
private long productVersion;
private boolean supportedProduct = true;
private Version wifiVersion;
private long lastRequestTimeMillis;
public DiscoveredLight(MACAddress macAddress, InetSocketAddress socketAddress) {
this.macAddress = macAddress;
this.socketAddress = socketAddress;
}
public boolean isDataComplete() {
return hostVersion != null && label != null && products != null && wifiVersion != null;
}
}
public LifxLightDiscovery() throws IllegalArgumentException {
super(LifxBindingConstants.SUPPORTED_THING_TYPES, 1, true);
}
@Override
protected void activate(Map<String, Object> configProperties) {
super.activate(configProperties);
broadcastAddresses = new ArrayList<InetSocketAddress>();
interfaceAddresses = new ArrayList<InetAddress>();
Enumeration<NetworkInterface> networkInterfaces = null;
try {
networkInterfaces = NetworkInterface.getNetworkInterfaces();
} catch (SocketException e) {
logger.debug("An exception occurred while discovering LIFX lights : '{}'", e.getMessage());
}
if (networkInterfaces != null) {
while (networkInterfaces.hasMoreElements()) {
NetworkInterface iface = networkInterfaces.nextElement();
try {
if (iface.isUp() && !iface.isLoopback()) {
for (InterfaceAddress ifaceAddr : iface.getInterfaceAddresses()) {
if (ifaceAddr.getAddress() instanceof Inet4Address) {
logger.debug("Adding '{}' as interface address with MTU {}", ifaceAddr.getAddress(),
iface.getMTU());
if (iface.getMTU() > bufferSize) {
bufferSize = iface.getMTU();
}
interfaceAddresses.add(ifaceAddr.getAddress());
if (ifaceAddr.getBroadcast() != null) {
logger.debug("Adding '{}' as broadcast address", ifaceAddr.getBroadcast());
broadcastAddresses
.add(new InetSocketAddress(ifaceAddr.getBroadcast(), BROADCAST_PORT));
}
}
}
}
} catch (SocketException e) {
logger.debug("An exception occurred while discovering LIFX lights : '{}'", e.getMessage());
}
}
}
}
@Override
protected void deactivate() {
super.deactivate();
}
@Override
protected void startBackgroundDiscovery() {
logger.debug("Starting the LIFX device background discovery");
Runnable discoveryRunnable = new Runnable() {
@Override
public void run() {
doScan();
}
};
if (discoveryJob == null || discoveryJob.isCancelled()) {
discoveryJob = scheduler.scheduleWithFixedDelay(discoveryRunnable, 0, REFRESH_INTERVAL, TimeUnit.SECONDS);
}
}
@Override
protected void stopBackgroundDiscovery() {
logger.debug("Stopping LIFX device background discovery");
if (discoveryJob != null && !discoveryJob.isCancelled()) {
discoveryJob.cancel(true);
discoveryJob = null;
}
if (networkJob != null && !networkJob.isCancelled()) {
networkJob.cancel(true);
networkJob = null;
}
}
@Override
protected void startScan() {
doScan();
}
@Override
protected synchronized void stopScan() {
super.stopScan();
removeOlderResults(getTimestampOfLastScan());
}
protected void doScan() {
try {
if (!isScanning) {
isScanning = true;
if (selector != null) {
selector.close();
}
if (broadcastChannel != null) {
broadcastChannel.close();
}
selector = Selector.open();
broadcastChannel = DatagramChannel.open(StandardProtocolFamily.INET)
.setOption(StandardSocketOptions.SO_REUSEADDR, true)
.setOption(StandardSocketOptions.SO_BROADCAST, true);
broadcastChannel.configureBlocking(false);
broadcastChannel.socket().setSoTimeout(BROADCAST_TIMEOUT);
broadcastChannel.bind(new InetSocketAddress(BROADCAST_PORT));
SelectionKey broadcastKey = broadcastChannel.register(selector,
SelectionKey.OP_READ | SelectionKey.OP_WRITE);
networkJob = scheduler.schedule(networkRunnable, 0, TimeUnit.MILLISECONDS);
source = UUID.randomUUID().getLeastSignificantBits() & (-1L >>> 32);
logger.debug("The LIFX discovery service will use '{}' as source identifier",
Long.toString(source, 16));
GetServiceRequest packet = new GetServiceRequest();
packet.setSequence(SERVICE_REQUEST_SEQ_NO);
packet.setSource(source);
broadcastPacket(packet, broadcastKey);
} else {
logger.info("A discovery scan for LIFX light is already underway");
}
} catch (Exception e) {
logger.debug("An exception occurred while discovering LIFX lights : '{}'", e.getMessage());
}
}
private void broadcastPacket(Packet packet, SelectionKey broadcastKey) {
for (InetSocketAddress address : broadcastAddresses) {
LifxNetworkThrottler.lock();
sendPacket(packet, address, broadcastKey);
LifxNetworkThrottler.unlock();
}
}
private void sendLightDataRequestPacket(DiscoveredLight light, Packet packet, int sequenceNumber,
SelectionKey unicastKey) {
packet.setTarget(light.macAddress);
packet.setSequence(sequenceNumber);
packet.setSource(source);
LifxNetworkThrottler.lock(light.macAddress);
sendPacket(packet, light.socketAddress, unicastKey);
LifxNetworkThrottler.unlock(light.macAddress);
}
private boolean sendPacket(Packet packet, InetSocketAddress address, SelectionKey selectedKey) {
boolean result = false;
try {
while (!result) {
selector.selectNow();
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (!result && keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isValid() && key.isWritable() && key.equals(selectedKey)) {
SelectableChannel channel = key.channel();
if (channel instanceof DatagramChannel) {
logger.trace(
"Discovery : Sending packet type '{}' from '{}' to '{}' for '{}' with sequence '{}' and source '{}'",
new Object[] { packet.getClass().getSimpleName(),
((InetSocketAddress) ((DatagramChannel) channel).getLocalAddress())
.toString(),
address.toString(), packet.getTarget().getHex(), packet.getSequence(),
Long.toString(packet.getSource(), 16) });
((DatagramChannel) channel).send(packet.bytes(), address);
result = true;
} else if (channel instanceof SocketChannel) {
((SocketChannel) channel).write(packet.bytes());
result = true;
}
}
}
}
} catch (Exception e) {
logger.debug("An exception occurred while sending a packet to the light : '{}'", e.getMessage());
}
return result;
}
private Runnable networkRunnable = new Runnable() {
@Override
public void run() {
try {
long startStamp = System.currentTimeMillis();
discoveredLights.clear();
logger.trace("Entering read loop at {}", startStamp);
while (System.currentTimeMillis() - startStamp < SELECTOR_TIMEOUT) {
if (selector != null && selector.isOpen()) {
try {
selector.selectNow();
} catch (IOException e) {
logger.error("An exception occurred while selecting: {}", e.getMessage());
}
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isValid() && key.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
// block of code only for completeness purposes
} else if (key.isValid() && key.isConnectable()) {
// a connection was established with a remote server.
// block of code only for completeness purposes
} else if (key.isValid() && key.isReadable()) {
// a channel is ready for reading
SelectableChannel channel = key.channel();
InetSocketAddress address = null;
int messageLength = 0;
ByteBuffer readBuffer = ByteBuffer.allocate(bufferSize);
try {
if (channel instanceof DatagramChannel) {
address = (InetSocketAddress) ((DatagramChannel) channel).receive(readBuffer);
} else if (channel instanceof SocketChannel) {
address = (InetSocketAddress) ((SocketChannel) channel).getRemoteAddress();
((SocketChannel) channel).read(readBuffer);
}
messageLength = readBuffer.position();
} catch (Exception e) {
logger.warn("An exception occurred while reading data : '{}'", e.getMessage());
}
if (address != null) {
logger.trace("Receiving data from {}", address.getAddress().toString());
if (!interfaceAddresses.contains(address.getAddress())) {
readBuffer.rewind();
ByteBuffer packetSize = readBuffer.slice();
packetSize.position(0);
packetSize.limit(2);
int size = Packet.FIELD_SIZE.value(packetSize);
if (messageLength == size) {
ByteBuffer packetType = readBuffer.slice();
packetType.position(32);
packetType.limit(34);
int type = Packet.FIELD_PACKET_TYPE.value(packetType);
PacketHandler<?> handler = PacketFactory.createHandler(type);
if (handler == null) {
logger.trace("Unknown packet type: {} (source: {})",
String.format("0x%02X", type), address.toString());
continue;
}
Packet packet = handler.handle(readBuffer);
if (packet == null) {
logger.warn("Handler {} was unable to handle packet",
handler.getClass().getName());
} else {
handlePacket(packet, address);
}
}
}
} else if (key.isValid() && key.isWritable()) {
// a channel is ready for writing
// block of code only for completeness purposes
}
}
}
requestAdditionalLightData();
}
}
isScanning = false;
} catch (Exception e) {
logger.debug("An exception occurred while communicating with the light : '{}'", e.getMessage(), e);
}
}
private void requestAdditionalLightData() throws IOException, ClosedChannelException {
// Iterate through the discovered lights that have to be set up, and the packets that have to be sent
// Workaround to avoid a ConcurrentModifictionException on the selector.SelectedKeys() Set
for (DiscoveredLight light : discoveredLights.values()) {
boolean waitingForLightResponse = System.currentTimeMillis() - light.lastRequestTimeMillis < 200;
if (light.supportedProduct && !light.isDataComplete() && !waitingForLightResponse) {
DatagramChannel unicastChannel = DatagramChannel.open(StandardProtocolFamily.INET)
.setOption(StandardSocketOptions.SO_REUSEADDR, true);
unicastChannel.configureBlocking(false);
SelectionKey unicastKey = unicastChannel.register(selector,
SelectionKey.OP_READ | SelectionKey.OP_WRITE);
unicastChannel.connect(light.socketAddress);
logger.trace("Connected to a light via {}", unicastChannel.getLocalAddress().toString());
if (light.products == null) {
sendLightDataRequestPacket(light, new GetVersionRequest(), VERSION_REQUEST_SEQ_NO, unicastKey);
}
if (light.label == null) {
sendLightDataRequestPacket(light, new GetLabelRequest(), LABEL_REQUEST_SEQ_NO, unicastKey);
}
if (light.hostVersion == null) {
sendLightDataRequestPacket(light, new GetHostFirmwareRequest(), HOST_VERSION_REQUEST_SEQ_NO,
unicastKey);
}
if (light.wifiVersion == null) {
sendLightDataRequestPacket(light, new GetWifiFirmwareRequest(), WIFI_VERSION_REQUEST_SEQ_NO,
unicastKey);
}
light.lastRequestTimeMillis = System.currentTimeMillis();
}
}
}
};
private void handlePacket(Packet packet, InetSocketAddress address) {
logger.trace("Discovery : Packet type '{}' received from '{}' for '{}' with sequence '{}' and source '{}'",
new Object[] { packet.getClass().getSimpleName(), address.toString(), packet.getTarget().getHex(),
packet.getSequence(), Long.toString(packet.getSource(), 16) });
if (packet.getSource() == source || packet.getSource() == 0) {
DiscoveredLight light = discoveredLights.get(packet.getTarget());
if (packet instanceof StateServiceResponse) {
int port = (int) ((StateServiceResponse) packet).getPort();
if (port != 0) {
try {
MACAddress macAddress = packet.getTarget();
InetSocketAddress socketAddress = new InetSocketAddress(address.getAddress(), port);
light = new DiscoveredLight(macAddress, socketAddress);
discoveredLights.put(macAddress, light);
} catch (Exception e) {
logger.warn("An exception occurred while connecting to IP address : '{}'", e.getMessage());
return;
}
}
} else if (packet instanceof StateLabelResponse) {
light.label = ((StateLabelResponse) packet).getLabel().trim();
} else if (packet instanceof StateVersionResponse) {
try {
light.products = Products.getProductFromProductID(((StateVersionResponse) packet).getProduct());
light.productVersion = ((StateVersionResponse) packet).getVersion();
} catch (IllegalArgumentException e) {
logger.debug("Discovered an unsupported light ({}): {}", light.macAddress.getAsLabel(),
e.getMessage());
light.supportedProduct = false;
}
} else if (packet instanceof StateHostFirmwareResponse) {
light.hostVersion = ((StateHostFirmwareResponse) packet).getVersion();
} else if (packet instanceof StateWifiFirmwareResponse) {
light.wifiVersion = ((StateWifiFirmwareResponse) packet).getVersion();
}
if (light != null && light.isDataComplete()) {
DiscoveryResult discoveryResult = createDiscoveryResult(light);
if (discoveryResult != null) {
thingDiscovered(discoveryResult);
}
}
}
}
private DiscoveryResult createDiscoveryResult(DiscoveredLight light) {
try {
String macAsLabel = light.macAddress.getAsLabel();
Products product = light.products;
ThingUID thingUID = new ThingUID(product.getThingTypeUID(), macAsLabel);
String label = light.label;
if (StringUtils.isBlank(label)) {
label = product.getName();
}
logger.trace("Discovered a LIFX light : {}", label);
DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(thingUID);
builder.withRepresentationProperty(macAsLabel);
builder.withLabel(label);
builder.withProperty(LifxBindingConstants.CONFIG_PROPERTY_DEVICE_ID, macAsLabel);
builder.withProperty(LifxBindingConstants.PROPERTY_HOST_VERSION, light.hostVersion.toString());
builder.withProperty(LifxBindingConstants.PROPERTY_MAC_ADDRESS, macAsLabel);
builder.withProperty(LifxBindingConstants.PROPERTY_PRODUCT_ID, light.products.getProduct());
builder.withProperty(LifxBindingConstants.PROPERTY_PRODUCT_NAME, light.products.getName());
builder.withProperty(LifxBindingConstants.PROPERTY_PRODUCT_VERSION, light.productVersion);
builder.withProperty(LifxBindingConstants.PROPERTY_VENDOR_ID, light.products.getVendor());
builder.withProperty(LifxBindingConstants.PROPERTY_VENDOR_NAME, light.products.getVendorName());
builder.withProperty(LifxBindingConstants.PROPERTY_WIFI_VERSION, light.wifiVersion.toString());
return builder.build();
} catch (IllegalArgumentException e) {
logger.trace("Ignoring packet: {}", e);
return null;
}
}
}