added migrated 2.x add-ons

Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Kai Kreuzer
2020-09-21 01:58:32 +02:00
parent bbf1a7fd29
commit 6df6783b60
11662 changed files with 1302875 additions and 11 deletions

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.milight-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-milight" description="Milight Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.milight/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link MilightBinding} class defines common constants, which are
* used across the whole binding.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MilightBindingConstants {
public static final String BINDING_ID = "milight";
// List of all Thing Type UIDs
public static final ThingTypeUID BRIDGEV3_THING_TYPE = new ThingTypeUID(BINDING_ID, "bridgeV3");
public static final ThingTypeUID BRIDGEV6_THING_TYPE = new ThingTypeUID(BINDING_ID, "bridgeV6");
public static final ThingTypeUID RGB_THING_TYPE = new ThingTypeUID(BINDING_ID, "rgbLed");
public static final ThingTypeUID RGB_V2_THING_TYPE = new ThingTypeUID(BINDING_ID, "rgbv2Led");
public static final ThingTypeUID RGB_IBOX_THING_TYPE = new ThingTypeUID(BINDING_ID, "rgbiboxLed");
public static final ThingTypeUID RGB_CW_WW_THING_TYPE = new ThingTypeUID(BINDING_ID, "rgbwwLed");
public static final ThingTypeUID RGB_W_THING_TYPE = new ThingTypeUID(BINDING_ID, "rgbwLed");
public static final ThingTypeUID WHITE_THING_TYPE = new ThingTypeUID(BINDING_ID, "whiteLed");
public static final Set<ThingTypeUID> BRIDGE_THING_TYPES_UIDS = Collections
.unmodifiableSet(Stream.of(BRIDGEV3_THING_TYPE, BRIDGEV6_THING_TYPE).collect(Collectors.toSet()));
// List of all Channel ids
public static final String CHANNEL_COLOR = "ledcolor";
public static final String CHANNEL_NIGHTMODE = "lednightmode";
public static final String CHANNEL_WHITEMODE = "ledwhitemode";
public static final String CHANNEL_BRIGHTNESS = "ledbrightness";
public static final String CHANNEL_SATURATION = "ledsaturation";
public static final String CHANNEL_TEMP = "ledtemperature";
public static final String CHANNEL_SPEED_REL = "animation_speed_relative";
public static final String CHANNEL_ANIMATION_MODE = "animation_mode";
public static final String CHANNEL_ANIMATION_MODE_REL = "animation_mode_relative";
public static final String CHANNEL_LINKLED = "ledlink";
public static final String CHANNEL_UNLINKLED = "ledunlink";
public static final int PORT_DISCOVER = 48899;
public static final int PORT_VER3 = 8899;
public static final int PORT_VER6 = 5987;
public static final String PROPERTY_SESSIONID = "sessionid";
public static final String PROPERTY_SESSIONCONFIRMED = "sessionid_last_refresh";
public static final byte[] DISCOVER_MSG_V3 = "Link_Wi-Fi".getBytes();
public static final byte[] DISCOVER_MSG_V6 = "HF-A11ASSISTHREAD".getBytes();
}

View File

@@ -0,0 +1,117 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal;
import static org.openhab.binding.milight.internal.MilightBindingConstants.*;
import java.io.IOException;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.milight.internal.handler.BridgeV3Handler;
import org.openhab.binding.milight.internal.handler.BridgeV6Handler;
import org.openhab.binding.milight.internal.handler.MilightV2RGBHandler;
import org.openhab.binding.milight.internal.handler.MilightV3RGBWHandler;
import org.openhab.binding.milight.internal.handler.MilightV3WhiteHandler;
import org.openhab.binding.milight.internal.handler.MilightV6RGBCWWWHandler;
import org.openhab.binding.milight.internal.handler.MilightV6RGBIBOXHandler;
import org.openhab.binding.milight.internal.handler.MilightV6RGBWHandler;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Component;
/**
* The {@link MilightHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.milight")
public class MilightHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.unmodifiableSet(
Stream.of(BRIDGEV3_THING_TYPE, BRIDGEV6_THING_TYPE, RGB_V2_THING_TYPE, RGB_THING_TYPE, WHITE_THING_TYPE,
RGB_W_THING_TYPE, RGB_CW_WW_THING_TYPE, RGB_IBOX_THING_TYPE).collect(Collectors.toSet()));
// The UDP queue for bridge communication is a single instance for all bridges.
// This is because all bridges use the same radio frequency, and if multiple
// bridges would send a command at the same time, they would interfere with
// each other (user report!).
private @Nullable QueuedSend queuedSend;
private int bridgeOffset;
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
final QueuedSend queuedSend = this.queuedSend;
if (queuedSend == null) {
return null;
}
if (thingTypeUID.equals(BRIDGEV3_THING_TYPE)) {
return new BridgeV3Handler((Bridge) thing, bridgeOffset += 100);
} else if (thingTypeUID.equals(BRIDGEV6_THING_TYPE)) {
return new BridgeV6Handler((Bridge) thing, bridgeOffset += 100);
} else if (thing.getThingTypeUID().equals(MilightBindingConstants.RGB_IBOX_THING_TYPE)) {
return new MilightV6RGBIBOXHandler(thing, queuedSend);
} else if (thing.getThingTypeUID().equals(MilightBindingConstants.RGB_CW_WW_THING_TYPE)) {
return new MilightV6RGBCWWWHandler(thing, queuedSend);
} else if (thing.getThingTypeUID().equals(MilightBindingConstants.RGB_W_THING_TYPE)) {
return new MilightV6RGBWHandler(thing, queuedSend);
} else if (thing.getThingTypeUID().equals(MilightBindingConstants.RGB_V2_THING_TYPE)) {
return new MilightV2RGBHandler(thing, queuedSend);
} else if (thing.getThingTypeUID().equals(MilightBindingConstants.WHITE_THING_TYPE)) {
return new MilightV3WhiteHandler(thing, queuedSend);
} else if (thing.getThingTypeUID().equals(MilightBindingConstants.RGB_THING_TYPE)) {
return new MilightV3RGBWHandler(thing, queuedSend);
}
return null;
}
@Override
protected void activate(ComponentContext componentContext) {
super.activate(componentContext);
queuedSend = new QueuedSend();
queuedSend.start();
bridgeOffset = 0;
}
@Override
protected void deactivate(ComponentContext componentContext) {
QueuedSend queuedSend = this.queuedSend;
if (queuedSend != null) {
try {
queuedSend.close();
} catch (IOException ignore) {
}
}
super.deactivate(componentContext);
}
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Contains a led bulb state including the HSB value, white color temperature and animation values.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MilightThingState {
public int animationMode;
public int colorTemperature; // only for led bulbs which include white leds applicable
public int brightness;
public int hue360; // only for rgb(w) leds applicable (v3+)
public int saturation; // only for rgbww leds applicable (v6+)
public MilightThingState() {
reset();
}
public void reset() {
animationMode = 0;
colorTemperature = 100; // only for led bulbs which include white leds applicable
brightness = 100;
hue360 = 180; // only for rgb(w) leds applicable (v3+)
saturation = 100; // only for rgbww leds applicable (v6+)
}
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.discovery;
import java.net.InetAddress;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Result callback interface.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public interface DiscoveryResultListener {
/**
* A Milight bridge got detected
*
* @param addr The IP address
* @param id The bridge ID
* @param version The bridge version (either 3 or 6)
*/
void bridgeDetected(InetAddress addr, String id, int version);
}

View File

@@ -0,0 +1,334 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.discovery;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.Enumeration;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.milight.internal.MilightBindingConstants;
import org.openhab.binding.milight.internal.handler.BridgeHandlerConfig;
import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager;
import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager.ISessionState;
import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager.SessionState;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Milight bridges v3/v4/v5 and v6 can be discovered by sending specially formated UDP packets.
* This class sends UDP packets on port PORT_DISCOVER up to three times in a row
* and listens for the response and will call discoverResult.bridgeDetected() eventually.
*
* The response of the bridges is unfortunately very generic and is the unmodified response of
* any HF-LPB100 wifi chipset. Therefore other devices as the Orvibo Smart Plugs are recognised
* as Milight Bridges as well. For v5/v6 there are some additional checks to make sure we are
* talking to a Milight.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.milight")
public class MilightBridgeDiscovery extends AbstractDiscoveryService implements Runnable {
private final Logger logger = LoggerFactory.getLogger(MilightBridgeDiscovery.class);
///// Static configuration
private static final boolean ENABLE_V3 = true;
private static final boolean ENABLE_V6 = true;
private @Nullable ScheduledFuture<?> backgroundFuture;
///// Network
private final int receivePort;
private final DatagramPacket discoverPacketV3;
private final DatagramPacket discoverPacketV6;
private boolean willbeclosed = false;
@NonNullByDefault({})
private DatagramSocket datagramSocket;
private final byte[] buffer = new byte[1024];
private final DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
///// Result and resend
private int resendCounter = 0;
private @Nullable ScheduledFuture<?> resendTimer;
private final int resendTimeoutInMillis;
private final int resendAttempts;
public MilightBridgeDiscovery() throws IllegalArgumentException, UnknownHostException {
super(MilightBindingConstants.BRIDGE_THING_TYPES_UIDS, 2, true);
this.resendAttempts = 2000 / 200;
this.resendTimeoutInMillis = 200;
this.receivePort = MilightBindingConstants.PORT_DISCOVER;
discoverPacketV3 = new DatagramPacket(MilightBindingConstants.DISCOVER_MSG_V3,
MilightBindingConstants.DISCOVER_MSG_V3.length);
discoverPacketV6 = new DatagramPacket(MilightBindingConstants.DISCOVER_MSG_V6,
MilightBindingConstants.DISCOVER_MSG_V6.length);
startDiscoveryService();
}
@Override
protected void startBackgroundDiscovery() {
if (backgroundFuture != null) {
return;
}
backgroundFuture = scheduler.scheduleWithFixedDelay(this::startDiscoveryService, 50, 60000 * 30,
TimeUnit.MILLISECONDS);
}
@Override
protected void stopBackgroundDiscovery() {
stopScan();
final ScheduledFuture<?> future = backgroundFuture;
if (future != null) {
future.cancel(false);
this.backgroundFuture = null;
}
stop();
}
public void bridgeDetected(InetAddress addr, String id, int version) {
ThingUID thingUID = new ThingUID(version == 6 ? MilightBindingConstants.BRIDGEV6_THING_TYPE
: MilightBindingConstants.BRIDGEV3_THING_TYPE, id);
Map<String, Object> properties = new TreeMap<>();
properties.put(BridgeHandlerConfig.CONFIG_BRIDGE_ID, id);
properties.put(BridgeHandlerConfig.CONFIG_HOST_NAME, addr.getHostAddress());
String label = "Bridge " + id;
DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withLabel(label)
.withProperties(properties).build();
thingDiscovered(discoveryResult);
}
@Override
protected void startScan() {
startDiscoveryService();
}
@Override
protected synchronized void stopScan() {
stop();
super.stopScan();
}
/**
* Used by the scheduler to resend discover messages. Stops after a configured amount of attempts.
*/
private class SendDiscoverRunnable implements Runnable {
@Override
public void run() {
// Stop after a certain amount of attempts
if (++resendCounter > resendAttempts) {
stop();
return;
}
Enumeration<NetworkInterface> e;
try {
e = NetworkInterface.getNetworkInterfaces();
} catch (SocketException e1) {
logger.error("Could not enumerate network interfaces for sending the discover packet!");
stop();
return;
}
while (e.hasMoreElements()) {
NetworkInterface networkInterface = e.nextElement();
for (InterfaceAddress address : networkInterface.getInterfaceAddresses()) {
InetAddress broadcast = address.getBroadcast();
if (broadcast != null && !address.getAddress().isLoopbackAddress()) {
sendDiscover(broadcast);
}
}
}
}
private void sendDiscover(InetAddress destIP) {
if (ENABLE_V3) {
discoverPacketV3.setAddress(destIP);
discoverPacketV3.setPort(MilightBindingConstants.PORT_DISCOVER);
try {
datagramSocket.send(discoverPacketV3);
} catch (IOException e) {
logger.error("Sending a V3 discovery packet to {} failed. {}", destIP.getHostAddress(),
e.getLocalizedMessage());
}
}
if (ENABLE_V6) {
discoverPacketV6.setAddress(destIP);
discoverPacketV6.setPort(MilightBindingConstants.PORT_DISCOVER);
try {
datagramSocket.send(discoverPacketV6);
} catch (IOException e) {
logger.error("Sending a V6 discovery packet to {} failed. {}", destIP.getHostAddress(),
e.getLocalizedMessage());
}
}
}
}
/**
* This will not stop the discovery thread (like dispose()), so discovery
* packet responses can still be received, but will stop
* re-sending discovery packets. Call sendDiscover() to restart sending
* discovery packets.
*/
public void stop() {
if (resendTimer != null) {
resendTimer.cancel(false);
resendTimer = null;
}
if (willbeclosed) {
return;
}
willbeclosed = true;
datagramSocket.close();
}
/**
* Send a discover message and resends the message until either a valid response
* is received or the resend counter reaches the maximum attempts.
*
* @param scheduler The scheduler is used for resending.
* @throws SocketException
*/
public void startDiscoveryService() {
// Do nothing if there is already a discovery running
if (resendTimer != null) {
return;
}
willbeclosed = false;
try {
datagramSocket = new DatagramSocket(null);
datagramSocket.setBroadcast(true);
datagramSocket.setReuseAddress(true);
datagramSocket.bind(null);
} catch (SocketException e) {
logger.error("Opening a socket for the milight discovery service failed. {}", e.getLocalizedMessage());
return;
}
resendCounter = 0;
resendTimer = scheduler.scheduleWithFixedDelay(new SendDiscoverRunnable(), 0, resendTimeoutInMillis,
TimeUnit.MILLISECONDS);
scheduler.execute(this);
}
@Override
public void run() {
try {
while (!willbeclosed) {
packet.setLength(buffer.length);
datagramSocket.receive(packet);
// We expect packets with a format like this: 10.1.1.27,ACCF23F57AD4,HF-LPB100
String[] msg = new String(buffer, 0, packet.getLength()).split(",");
if (msg.length != 2 && msg.length != 3) {
// That data packet does not belong to a Milight bridge. Just ignore it.
continue;
}
// First argument is the IP
try {
InetAddress.getByName(msg[0]);
} catch (UnknownHostException ignored) {
// That data packet does not belong to a Milight bridge, we expect an IP address as first argument.
// Just ignore it.
continue;
}
// Second argument is the MAC address
if (msg[1].length() != 12) {
// That data packet does not belong to a Milight bridge, we expect a MAC address as second argument.
// Just ignore it.
continue;
}
InetAddress addressOfBridge = ((InetSocketAddress) packet.getSocketAddress()).getAddress();
if (ENABLE_V6 && msg.length == 3) {
if (!(msg[2].length() == 0 || "HF-LPB100".equals(msg[2]))) {
logger.trace("Unexpected data. We expected a HF-LPB100 or empty identifier {}", msg[2]);
continue;
}
if (!checkForV6Bridge(addressOfBridge, msg[1])) {
logger.trace("The device at IP {} does not seem to be a V6 Milight bridge", msg[0]);
continue;
}
bridgeDetected(addressOfBridge, msg[1], 6);
} else if (ENABLE_V3 && msg.length == 2) {
bridgeDetected(addressOfBridge, msg[1], 3);
} else {
logger.debug("Unexpected data. Expected Milight bridge message");
}
}
} catch (IOException e) {
if (willbeclosed) {
return;
}
logger.warn("{}", e.getLocalizedMessage());
} catch (InterruptedException ignore) {
// Ignore this exception, the thread is finished now anyway
}
}
/**
* We use the {@see MilightV6SessionManager} to establish a full session to the bridge. If we reach
* the SESSION_VALID state within 1.3s, we can safely assume it is a V6 Milight bridge.
*
* @param addressOfBridge IP Address of the bridge
* @return
* @throws InterruptedException If waiting for the session is interrupted we throw this exception
*/
private boolean checkForV6Bridge(InetAddress addressOfBridge, String bridgeID) throws InterruptedException {
Semaphore s = new Semaphore(0);
ISessionState sessionState = (SessionState state, InetAddress address) -> {
if (state == SessionState.SESSION_VALID) {
s.release();
}
logger.debug("STATE CHANGE: {}", state);
};
try (MilightV6SessionManager session = new MilightV6SessionManager(bridgeID, sessionState, addressOfBridge,
MilightBindingConstants.PORT_VER6, MilightV6SessionManager.TIMEOUT_MS, new byte[] { 0, 0 })) {
session.start();
boolean success = s.tryAcquire(1, 1300, TimeUnit.MILLISECONDS);
return success;
} catch (IOException e) {
logger.debug("checkForV6Bridge failed", e);
}
return false;
}
}

View File

@@ -0,0 +1,92 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AbstractBridgeHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public abstract class AbstractBridgeHandler extends BaseBridgeHandler {
protected final Logger logger = LoggerFactory.getLogger(AbstractBridgeHandler.class);
protected volatile boolean preventReinit = false;
protected BridgeHandlerConfig config = new BridgeHandlerConfig();
protected @Nullable InetAddress address;
protected @NonNullByDefault({}) DatagramSocket socket;
protected int bridgeOffset;
public AbstractBridgeHandler(Bridge bridge, int bridgeOffset) {
super(bridge);
this.bridgeOffset = bridgeOffset;
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
// There is nothing to handle in the bridge handler
}
@Override
public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
if (preventReinit) {
return;
}
super.handleConfigurationUpdate(configurationParameters);
}
/**
* Creates a connection and other supportive objects.
*
* @param addr
*/
protected abstract void startConnectAndKeepAlive();
/**
* You need a CONFIG_HOST_NAME and CONFIG_ID for a milight bridge handler to initialize correctly.
* The ID is a unique 12 character long ASCII based on the bridge MAC address (for example ACCF23A20164)
* and is send as response for a discovery message.
*/
@Override
public void initialize() {
config = getConfigAs(BridgeHandlerConfig.class);
if (!config.host.isEmpty()) {
try {
address = InetAddress.getByName(config.host);
} catch (UnknownHostException ignored) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Address set, but is invalid!");
return;
}
}
startConnectAndKeepAlive();
}
}

View File

@@ -0,0 +1,320 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.milight.internal.MilightBindingConstants;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.IncreaseDecreaseType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AbstractLedHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public abstract class AbstractLedHandler extends BaseThingHandler implements LedHandlerInterface {
private final Logger logger = LoggerFactory.getLogger(AbstractLedHandler.class);
protected final QueuedSend sendQueue;
/** Each bulb type including zone has to be unique. -> Each type has an offset. */
protected final int typeOffset;
protected final MilightThingState state = new MilightThingState();
protected LedHandlerConfig config = new LedHandlerConfig();
protected int port = 0;
protected @NonNullByDefault({}) InetAddress address;
protected @NonNullByDefault({}) DatagramSocket socket;
protected int delayTimeMS = 50;
protected int repeatTimes = 3;
protected int bridgeOffset;
/**
* A bulb always belongs to a zone in the milight universe and we need a way to queue commands for being send.
*
* @param typeOffset Each bulb type including its zone has to be unique. To realise this, each type has an offset.
* @param sendQueue The send queue.
* @param zone A zone, usually 0 means all bulbs of the same type. [0-4]
* @throws SocketException
*/
public AbstractLedHandler(Thing thing, QueuedSend sendQueue, int typeOffset) {
super(thing);
this.typeOffset = typeOffset;
this.sendQueue = sendQueue;
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
switch (channelUID.getId()) {
case MilightBindingConstants.CHANNEL_COLOR:
updateState(channelUID, new HSBType(new DecimalType(state.hue360),
new PercentType(state.saturation), new PercentType(state.brightness)));
break;
case MilightBindingConstants.CHANNEL_BRIGHTNESS:
updateState(channelUID, new PercentType(state.brightness));
break;
case MilightBindingConstants.CHANNEL_SATURATION:
updateState(channelUID, new PercentType(state.saturation));
break;
case MilightBindingConstants.CHANNEL_TEMP:
updateState(channelUID, new PercentType(state.colorTemperature));
break;
case MilightBindingConstants.CHANNEL_ANIMATION_MODE:
updateState(channelUID, new DecimalType(state.animationMode));
break;
}
return;
}
switch (channelUID.getId()) {
case MilightBindingConstants.CHANNEL_COLOR: {
if (command instanceof HSBType) {
HSBType hsb = (HSBType) command;
this.setHSB(hsb.getHue().intValue(), hsb.getSaturation().intValue(), hsb.getBrightness().intValue(),
state);
updateState(MilightBindingConstants.CHANNEL_SATURATION, new PercentType(state.saturation));
} else if (command instanceof OnOffType) {
OnOffType hsb = (OnOffType) command;
this.setPower(hsb == OnOffType.ON, state);
} else if (command instanceof PercentType) {
PercentType p = (PercentType) command;
this.setBrightness(p.intValue(), state);
} else if (command instanceof IncreaseDecreaseType) {
this.changeBrightness((IncreaseDecreaseType) command == IncreaseDecreaseType.INCREASE ? 1 : -1,
state);
} else {
logger.error(
"CHANNEL_COLOR channel only supports OnOffType/IncreaseDecreaseType/HSBType/PercentType");
}
updateState(MilightBindingConstants.CHANNEL_BRIGHTNESS, new PercentType(state.brightness));
break;
}
case MilightBindingConstants.CHANNEL_NIGHTMODE: {
this.nightMode(state);
updateState(channelUID, UnDefType.UNDEF);
break;
}
case MilightBindingConstants.CHANNEL_WHITEMODE: {
this.whiteMode(state);
updateState(channelUID, UnDefType.UNDEF);
break;
}
case MilightBindingConstants.CHANNEL_BRIGHTNESS: {
if (command instanceof OnOffType) {
OnOffType hsb = (OnOffType) command;
this.setPower(hsb == OnOffType.ON, state);
} else if (command instanceof DecimalType) {
DecimalType d = (DecimalType) command;
this.setBrightness(d.intValue(), state);
} else if (command instanceof IncreaseDecreaseType) {
this.changeBrightness((IncreaseDecreaseType) command == IncreaseDecreaseType.INCREASE ? 1 : -1,
state);
} else {
logger.error("CHANNEL_BRIGHTNESS channel only supports OnOffType/IncreaseDecreaseType/DecimalType");
}
updateState(MilightBindingConstants.CHANNEL_COLOR, new HSBType(new DecimalType(state.hue360),
new PercentType(state.saturation), new PercentType(state.brightness)));
break;
}
case MilightBindingConstants.CHANNEL_SATURATION: {
if (command instanceof OnOffType) {
OnOffType s = (OnOffType) command;
this.setSaturation((s == OnOffType.ON) ? 100 : 0, state);
} else if (command instanceof DecimalType) {
DecimalType d = (DecimalType) command;
this.setSaturation(d.intValue(), state);
} else if (command instanceof IncreaseDecreaseType) {
this.changeSaturation((IncreaseDecreaseType) command == IncreaseDecreaseType.INCREASE ? 1 : -1,
state);
} else {
logger.error("CHANNEL_SATURATION channel only supports OnOffType/IncreaseDecreaseType/DecimalType");
}
updateState(MilightBindingConstants.CHANNEL_COLOR, new HSBType(new DecimalType(state.hue360),
new PercentType(state.saturation), new PercentType(state.brightness)));
break;
}
case MilightBindingConstants.CHANNEL_TEMP: {
if (command instanceof OnOffType) {
OnOffType s = (OnOffType) command;
this.setColorTemperature((s == OnOffType.ON) ? 100 : 0, state);
} else if (command instanceof IncreaseDecreaseType) {
this.changeColorTemperature(
(IncreaseDecreaseType) command == IncreaseDecreaseType.INCREASE ? 1 : -1, state);
} else if (command instanceof DecimalType) {
DecimalType d = (DecimalType) command;
this.setColorTemperature(d.intValue(), state);
} else {
logger.error("CHANNEL_TEMP channel only supports OnOffType/IncreaseDecreaseType/DecimalType");
}
break;
}
case MilightBindingConstants.CHANNEL_SPEED_REL: {
if (command instanceof IncreaseDecreaseType) {
IncreaseDecreaseType id = (IncreaseDecreaseType) command;
if (id == IncreaseDecreaseType.INCREASE) {
this.changeSpeed(1, state);
} else if (id == IncreaseDecreaseType.DECREASE) {
this.changeSpeed(-1, state);
}
} else {
logger.error("CHANNEL_SPEED channel only supports IncreaseDecreaseType");
}
break;
}
case MilightBindingConstants.CHANNEL_ANIMATION_MODE: {
if (command instanceof DecimalType) {
DecimalType d = (DecimalType) command;
this.setLedMode(d.intValue(), state);
} else {
logger.error("Animation mode channel only supports DecimalType");
}
break;
}
case MilightBindingConstants.CHANNEL_ANIMATION_MODE_REL: {
if (command instanceof IncreaseDecreaseType) {
IncreaseDecreaseType id = (IncreaseDecreaseType) command;
if (id == IncreaseDecreaseType.INCREASE) {
this.nextAnimationMode(state);
} else if (id == IncreaseDecreaseType.DECREASE) {
this.previousAnimationMode(state);
}
} else {
logger.error("Relative animation mode channel only supports IncreaseDecreaseType");
}
break;
}
default:
logger.error("Channel unknown {}", channelUID.getId());
}
}
/**
* Return the bride handler.
*/
public @Nullable AbstractBridgeHandler getBridgeHandler() {
Bridge bridge = getBridge();
if (bridge == null) {
return null;
}
return (AbstractBridgeHandler) bridge.getHandler();
}
/**
* Return the bridge status.
*/
public ThingStatusInfo getBridgeStatus() {
Bridge b = getBridge();
if (b != null) {
return b.getStatusInfo();
} else {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, null);
}
}
/**
* Generates a unique command id for the {@see QueuedSend}. It incorporates the bridge, zone, bulb type and command
* category.
*
* @param commandCategory The category of the command.
*
* @return
*/
public int uidc(int commandCategory) {
return (bridgeOffset + config.zone + typeOffset + 1) * 64 + commandCategory;
}
protected void start(AbstractBridgeHandler handler) {
}
@Override
public void bridgeStatusChanged(@NonNull ThingStatusInfo bridgeStatusInfo) {
if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
return;
}
if (bridgeStatusInfo.getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
return;
}
AbstractBridgeHandler h = getBridgeHandler();
if (h == null) {
logger.warn("Bridge handler not found!");
return;
}
final InetAddress inetAddress = h.address;
if (inetAddress == null) {
logger.warn("Bridge handler has not yet determined the IP address!");
return;
}
state.reset();
configUpdated(h, inetAddress);
if (h.getThing().getStatus() == ThingStatus.ONLINE) {
updateStatus(ThingStatus.ONLINE);
start(h);
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
}
}
/**
* Called by the bridge if a configuration update happened after initialisation has been done
*
* @param h The bridge handler
*/
public void configUpdated(AbstractBridgeHandler h, InetAddress address) {
this.port = h.config.port;
this.address = address;
this.socket = h.socket;
this.delayTimeMS = h.config.delayTime;
this.repeatTimes = h.config.repeat;
this.bridgeOffset = h.bridgeOffset;
}
@Override
public void initialize() {
config = getConfigAs(LedHandlerConfig.class);
bridgeStatusChanged(getBridgeStatus());
}
}

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.QueueItem;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.Thing;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class implements common functionality for Milight/Easybulb bulbs of protocol version 3.
* Most of the implementation is found in the specific bulb classes though.
* The class is state-less, use {@link MilightThingState} instead.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public abstract class AbstractLedV3Handler extends AbstractLedHandler {
public static final int MAX_ANIM_MODES = 10;
protected final Logger logger = LoggerFactory.getLogger(AbstractLedV3Handler.class);
public AbstractLedV3Handler(Thing thing, QueuedSend sendQueue, int typeOffset) {
super(thing, sendQueue, typeOffset);
}
// we have to map [0,360] to [0,0xFF], where red equals hue=0 and the milight color 0xB0 (=176)
public static byte makeColor(int hue) {
int mHue = (360 + 248 - hue) % 360; // invert and shift
return (byte) (mHue * 255 / 360); // map to 256 values
}
@Override
public void setLedMode(int mode, MilightThingState state) {
// Not supported
}
@Override
public void setSaturation(int value, MilightThingState state) {
// Not supported
}
@Override
public void changeSaturation(int relativeSaturation, MilightThingState state) {
// Not supported
}
protected QueueItem createRepeatable(byte[] data) {
return QueueItem.createRepeatable(socket, delayTimeMS, repeatTimes, address, port, data);
}
protected QueueItem createRepeatable(int uidc, byte[] data) {
return new QueueItem(socket, uidc, data, true, delayTimeMS, repeatTimes, address, port);
}
protected QueueItem createNonRepeatable(byte[] data) {
return QueueItem.createNonRepeatable(socket, delayTimeMS, address, port, data);
}
}

View File

@@ -0,0 +1,224 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.milight.internal.MilightBindingConstants;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager;
import org.openhab.binding.milight.internal.protocol.ProtocolConstants;
import org.openhab.binding.milight.internal.protocol.QueueItem;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Implements basic V6 bulb functionally. But commands are different for different v6 bulbs, so almost all the work is
* done in subclasses.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public abstract class AbstractLedV6Handler extends AbstractLedHandler {
protected final Logger logger = LoggerFactory.getLogger(AbstractLedV6Handler.class);
protected static final int MAX_BR = 100; // Maximum brightness (0x64)
protected static final int MAX_SAT = 100; // Maximum saturation (0x64)
protected static final int MAX_TEMP = 100; // Maximum colour temperature (0x64)
protected @NonNullByDefault({}) MilightV6SessionManager session;
public AbstractLedV6Handler(Thing thing, QueuedSend sendQueue, int typeOffset) {
super(thing, sendQueue, typeOffset);
}
@Override
protected void start(AbstractBridgeHandler handler) {
BridgeV6Handler h = (BridgeV6Handler) handler;
session = h.getSessionManager();
}
protected abstract byte getAddr();
protected abstract byte getBrCmd();
@Override
public void setHSB(int hue, int saturation, int brightness, MilightThingState state) {
if (hue > 360 || hue < 0) {
logger.error("Hue argument out of range");
return;
}
// 0xFF = Red, D9 = Lavender, BA = Blue, 85 = Aqua, 7A = Green, 54 = Lime, 3B = Yellow, 1E = Orange
// we have to map [0,360] to [0,0xFF], where red equals hue=0 and the milight color 0xFF (=255)
// Integer milightColorNo = (256 + 0xFF - (int) (hue / 360.0 * 255.0)) % 256;
// Compute destination hue and current hue value, each mapped to 256 values.
// int cHue = state.hue360 * 255 / 360; // map to 256 values
int dHue = hue * 255 / 360; // map to 256 values
sendRepeatableCat(ProtocolConstants.CAT_COLOR_SET, 1, dHue, dHue, dHue, dHue);
state.hue360 = hue;
if (brightness != -1) {
setBrightness(brightness, state);
}
if (saturation != -1) {
setSaturation(saturation, state);
}
}
@Override
public void setBrightness(int newvalue, MilightThingState state) {
int value = Math.min(Math.max(newvalue, 0), 100);
if (value == 0) {
setPower(false, state);
} else if (state.brightness == 0) {
// If if was dimmed to minimum (off), turn it on again
setPower(true, state);
}
int br = (value * MAX_BR) / 100;
br = Math.min(br, MAX_BR);
br = Math.max(br, 0);
sendRepeatableCat(ProtocolConstants.CAT_BRIGHTNESS_SET, getBrCmd(), br);
state.brightness = value;
}
@Override
public void changeColorTemperature(int colorTempRelative, MilightThingState state) {
if (!session.isValid()) {
logger.error("Bridge communication session not valid yet!");
return;
}
if (colorTempRelative == 0) {
return;
}
int ct = (state.colorTemperature * MAX_TEMP) / 100 + colorTempRelative;
ct = Math.min(ct, MAX_TEMP);
ct = Math.max(ct, 0);
setColorTemperature(ct * 100 / MAX_TEMP, state);
}
@Override
public void changeBrightness(int relativeBrightness, MilightThingState state) {
if (!session.isValid()) {
logger.error("Bridge communication session not valid yet!");
return;
}
if (relativeBrightness == 0) {
return;
}
int br = (state.brightness * MAX_BR) / 100 + relativeBrightness;
br = Math.min(br, MAX_BR);
br = Math.max(br, 0);
setBrightness(br * 100 / MAX_BR, state);
}
@Override
public void changeSaturation(int relativeSaturation, MilightThingState state) {
if (!session.isValid()) {
logger.error("Bridge communication session not valid yet!");
return;
}
if (relativeSaturation == 0) {
return;
}
int br = (state.brightness * MAX_BR) / 100 + relativeSaturation;
br = Math.min(br, MAX_BR);
br = Math.max(br, 0);
setSaturation(br * 100 / MAX_BR, state);
}
@Override
public void previousAnimationMode(MilightThingState state) {
if (!session.isValid()) {
logger.error("Bridge communication session not valid yet!");
return;
}
int mode = state.animationMode - 1;
mode = Math.min(mode, 9);
mode = Math.max(mode, 1);
setLedMode(mode, state);
}
@Override
public void nextAnimationMode(MilightThingState state) {
if (!session.isValid()) {
logger.error("Bridge communication session not valid yet!");
return;
}
int mode = state.animationMode + 1;
mode = Math.min(mode, 9);
mode = Math.max(mode, 1);
setLedMode(mode, state);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
super.handleCommand(channelUID, command);
return;
}
switch (channelUID.getId()) {
case MilightBindingConstants.CHANNEL_LINKLED: {
sendQueue.queue(new QueueItem(socket, uidc(ProtocolConstants.CAT_LINK),
session.makeLink(getAddr(), config.zone, true), true, delayTimeMS, repeatTimes, address, port));
break;
}
case MilightBindingConstants.CHANNEL_UNLINKLED: {
sendQueue.queue(new QueueItem(socket, uidc(ProtocolConstants.CAT_LINK),
session.makeLink(getAddr(), config.zone, false), true, delayTimeMS, repeatTimes, address,
port));
break;
}
default:
super.handleCommand(channelUID, command);
}
}
protected void sendNonRepeatable(int... data) {
sendQueue.queue(QueueItem.createNonRepeatable(socket, delayTimeMS, address, port,
session.makeCommand(getAddr(), config.zone, data)));
}
protected void sendRepeatableCat(int cat, int... data) {
sendQueue.queue(new QueueItem(socket, uidc(cat), session.makeCommand(getAddr(), config.zone, data), true,
delayTimeMS, repeatTimes, address, port));
}
protected void sendRepeatable(int... data) {
sendQueue.queue(QueueItem.createRepeatable(socket, delayTimeMS, repeatTimes, address, port,
session.makeCommand(getAddr(), config.zone, data)));
}
}

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
/**
* The configuration for all bridge types.
*
* @author David Graeff - Initial contribution
*/
public class BridgeHandlerConfig {
public static final String CONFIG_BRIDGE_ID = "bridgeid";
String bridgeid = "";
public static final String CONFIG_HOST_NAME = "host";
String host = "";
int refreshTime = 5000;
int port = 0;
int passwordByte1 = 0;
int passwordByte2 = 0;
int repeat = 3;
int delayTime = 100;
}

View File

@@ -0,0 +1,196 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.milight.internal.MilightBindingConstants;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
/**
* The {@link BridgeV3Handler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class BridgeV3Handler extends AbstractBridgeHandler {
protected final DatagramPacket discoverPacketV3;
protected final byte[] buffer = new byte[1024];
protected final DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
private @Nullable ScheduledFuture<?> running;
public BridgeV3Handler(Bridge bridge, int bridgeOffset) {
super(bridge, bridgeOffset);
discoverPacketV3 = new DatagramPacket(MilightBindingConstants.DISCOVER_MSG_V3,
MilightBindingConstants.DISCOVER_MSG_V3.length);
}
@Override
public void thingUpdated(Thing thing) {
super.thingUpdated(thing);
}
/**
* Creates a discovery object and the send queue. The initial IP address may be null
* or is not matching with the real IP address of the bridge. The discovery class will send
* a broadcast packet to find the bridge with the respective bridge ID. The response in bridgeDetected()
* may lead to a recreation of the send queue object.
*
* The keep alive timer that is also setup here, will send keep alive packets periodically.
* If the bridge doesn't respond anymore (e.g. DHCP IP change), the initial session handshake
* starts all over again.
*/
@Override
protected void startConnectAndKeepAlive() {
if (address == null) {
if (!config.bridgeid.matches("^([0-9A-Fa-f]{12})$")) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "bridgeID invalid!");
return;
}
try {
address = InetAddress.getByAddress(new byte[] { (byte) 255, (byte) 255, (byte) 255, (byte) 255 });
} catch (UnknownHostException neverHappens) {
}
}
if (config.port == 0) {
config.port = MilightBindingConstants.PORT_VER3;
}
try {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.setBroadcast(true);
socket.bind(null);
} catch (SocketException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
}
running = scheduler.scheduleWithFixedDelay(this::receive, 0, config.refreshTime, TimeUnit.SECONDS);
}
protected void stopKeepAlive() {
if (running != null) {
running.cancel(false);
running = null;
}
if (socket != null) {
socket.close();
}
}
@Override
public void dispose() {
stopKeepAlive();
}
private void receive() {
try {
discoverPacketV3.setAddress(address);
discoverPacketV3.setPort(MilightBindingConstants.PORT_DISCOVER);
final int attempts = 5;
int timeoutsCounter = 0;
for (timeoutsCounter = 1; timeoutsCounter <= attempts; ++timeoutsCounter) {
try {
packet.setLength(buffer.length);
socket.setSoTimeout(500 * timeoutsCounter);
socket.send(discoverPacketV3);
socket.receive(packet);
} catch (SocketTimeoutException e) {
continue;
}
// We expect packets with a format like this: 10.1.1.27,ACCF23F57AD4,HF-LPB100
final String received = new String(packet.getData());
final String[] msg = received.split(",");
if (msg.length != 2 && msg.length != 3) {
// That data packet does not belong to a Milight bridge. Just ignore it.
continue;
}
// First argument is the IP
try {
InetAddress.getByName(msg[0]);
} catch (UnknownHostException ignored) {
// That data packet does not belong to a Milight bridge, we expect an IP address as first
// argument. Just ignore it.
continue;
}
// Second argument is the MAC address
if (msg[1].length() != 12) {
// That data packet does not belong to a Milight bridge, we expect a MAC address as second
// argument.
// Just ignore it.
continue;
}
final InetAddress addressOfBridge = ((InetSocketAddress) packet.getSocketAddress()).getAddress();
final String bridgeID = msg[1];
if (!config.bridgeid.isEmpty() && !bridgeID.equals(config.bridgeid)) {
// We found a bridge, but it is not the one that is handled by this handler
if (!config.host.isEmpty()) { // The user has set a host address -> but wrong bridge found!
stopKeepAlive();
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Wrong bridge found on host address. Change bridgeid or host configuration.");
break;
}
continue;
}
// IP address has changed, reestablish communication
if (!addressOfBridge.equals(this.address)) {
this.address = addressOfBridge;
Configuration c = editConfiguration();
c.put(BridgeHandlerConfig.CONFIG_HOST_NAME, addressOfBridge.getHostAddress());
preventReinit = true;
updateConfiguration(c);
preventReinit = false;
} else if (config.bridgeid.isEmpty()) { // bridge id was not set and is now known. Store it.
config.bridgeid = bridgeID;
Configuration c = editConfiguration();
c.put(BridgeHandlerConfig.CONFIG_BRIDGE_ID, bridgeID);
preventReinit = true;
updateConfiguration(c);
preventReinit = false;
}
updateStatus(ThingStatus.ONLINE);
break;
}
if (timeoutsCounter > attempts) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Bridge did not respond!");
}
} catch (IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}
}

View File

@@ -0,0 +1,148 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import java.io.IOException;
import java.net.InetAddress;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.milight.internal.MilightBindingConstants;
import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager;
import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager.ISessionState;
import org.openhab.binding.milight.internal.protocol.MilightV6SessionManager.SessionState;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
/**
* The {@link BridgeV6Handler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class BridgeV6Handler extends AbstractBridgeHandler implements ISessionState {
private @NonNullByDefault({}) MilightV6SessionManager session;
final DateTimeFormatter timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
private String offlineReason = "";
private @Nullable ScheduledFuture<?> scheduledFuture;
public BridgeV6Handler(Bridge bridge, int bridgeOffset) {
super(bridge, bridgeOffset);
}
/**
* Creates a session manager object and the send queue. The initial IP address may be null
* or is not matching with the real IP address of the bridge. The session manager will send
* a broadcast packet to find the bridge with the respective bridge ID and will change the
* IP address of the send queue object accordingly.
*
* The keep alive timer that is also setup here, will send keep alive packets periodically.
* If the bridge doesn't respond anymore (e.g. DHCP IP change), the initial session handshake
* starts all over again.
*/
@Override
protected void startConnectAndKeepAlive() {
if (!config.bridgeid.matches("^([0-9A-Fa-f]{12})$")) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "bridgeID invalid!");
return;
}
if (config.port == 0) {
config.port = MilightBindingConstants.PORT_VER6;
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Waiting for session");
int refreshTime = Math.max(Math.min(config.refreshTime, MilightV6SessionManager.TIMEOUT_MS), 100);
this.session = new MilightV6SessionManager(config.bridgeid, this, address, config.port, refreshTime,
new byte[] { (byte) config.passwordByte1, (byte) config.passwordByte2 });
session.start().thenAccept(d -> this.socket = d);
}
@Override
public void dispose() {
stopMakeOfflineTimer();
try {
session.close();
} catch (IOException ignore) {
}
this.session = null;
super.dispose();
}
public MilightV6SessionManager getSessionManager() {
return session;
}
@Override
public void sessionStateChanged(SessionState state, @Nullable InetAddress newAddress) {
stopMakeOfflineTimer();
switch (state) {
case SESSION_VALID_KEEP_ALIVE:
preventReinit = true;
Instant lastSessionTime = session.getLastSessionValidConfirmation();
LocalDateTime date = LocalDateTime.ofInstant(lastSessionTime, ZoneId.systemDefault());
updateProperty(MilightBindingConstants.PROPERTY_SESSIONCONFIRMED, date.format(timeFormat));
preventReinit = false;
break;
case SESSION_VALID:
if (newAddress == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No IP address received");
break;
}
if (!newAddress.equals(address) || !thing.getStatus().equals(ThingStatus.ONLINE)) {
updateStatus(ThingStatus.ONLINE);
this.address = newAddress;
// As soon as the session is valid, update the user visible configuration of the host IP.
Configuration c = editConfiguration();
c.put(BridgeHandlerConfig.CONFIG_HOST_NAME, newAddress.getHostAddress());
thing.setProperty(MilightBindingConstants.PROPERTY_SESSIONID, session.getSession());
thing.setProperty(MilightBindingConstants.PROPERTY_SESSIONCONFIRMED,
String.valueOf(session.getLastSessionValidConfirmation()));
preventReinit = true;
updateConfiguration(c);
preventReinit = false;
}
break;
case SESSION_INVALID:
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Session could not be established");
break;
default:
// Delay putting the session offline
offlineReason = state.name();
scheduledFuture = scheduler.schedule(
() -> updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, offlineReason),
1000, TimeUnit.MILLISECONDS);
break;
}
}
private void stopMakeOfflineTimer() {
ScheduledFuture<?> future = scheduledFuture;
if (future != null) {
future.cancel(false);
scheduledFuture = null;
}
}
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
/**
* The configuration for all led types.
*
* @author David Graeff - Initial contribution
*/
public class LedHandlerConfig {
public static final String CONFIG_ZONE = "zone";
int zone = 0;
}

View File

@@ -0,0 +1,89 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.milight.internal.MilightThingState;
/**
* The {@link LedHandlerInterface} defines the general interface for all Milight bulbs.
* The different versions might support additional functionality.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public interface LedHandlerInterface {
/**
* Set the color value of this bulb.
*
* @param hue A value from 0 to 360
* @param saturation A saturation value. Can be -1 if not known
* @param brightness A brightness value. Can be -1 if not known
* @param state The changed values will be written back to the state
*/
void setHSB(int hue, int saturation, int brightness, MilightThingState state);
/**
* Enable/Disable the bulb.
*
* @param on On/Off
* @param state The changed values will be written back to the state
*/
void setPower(boolean on, MilightThingState state);
/**
* Switches to white mode (disables color leds).
*
* @param state The changed values will be written back to the state
*/
void whiteMode(MilightThingState state);
/**
* Switches to night mode (low current for all leds).
*
* @param state The changed values will be written back to the state
*/
void nightMode(MilightThingState state);
/**
* Sets the color temperature of the bulb.
*
* @param colorTemp Color temperature percentage
* @param state The changed values will be written back to the state
*/
void setColorTemperature(int colorTemp, MilightThingState state);
void changeColorTemperature(int colorTempRelative, MilightThingState state);
/**
* Sets the brightness of the bulb.
*
* @param value brightness percentage
* @param state The changed values will be written back to the state
*/
void setBrightness(int value, MilightThingState state);
void changeBrightness(int relativeBrightness, MilightThingState state);
void setSaturation(int value, MilightThingState state);
void changeSaturation(int relativeSaturation, MilightThingState state);
void setLedMode(int mode, MilightThingState state);
void previousAnimationMode(MilightThingState state);
void nextAnimationMode(MilightThingState state);
void changeSpeed(int relativeSpeed, MilightThingState state);
}

View File

@@ -0,0 +1,181 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.ProtocolConstants;
import org.openhab.binding.milight.internal.protocol.QueueItem;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.Thing;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Implements functionality for the very first RGB bulbs for protocol version 2.
* A lot of stuff is not supported by this type of bulbs and they are not auto discovered
* and can only be add manually in the things file.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MilightV2RGBHandler extends AbstractLedHandler {
protected final Logger logger = LoggerFactory.getLogger(MilightV2RGBHandler.class);
protected static final int BR_LEVELS = 9;
public MilightV2RGBHandler(Thing thing, QueuedSend sendQueue) {
super(thing, sendQueue, 5);
}
protected QueueItem createRepeatable(byte[] data) {
return QueueItem.createRepeatable(socket, delayTimeMS, repeatTimes, address, port, data);
}
protected QueueItem createRepeatable(int uidc, byte[] data) {
return new QueueItem(socket, uidc, data, true, delayTimeMS, repeatTimes, address, port);
}
protected QueueItem createNonRepeatable(byte[] data) {
return QueueItem.createNonRepeatable(socket, delayTimeMS, address, port, data);
}
// We have no real saturation control for RGB bulbs. If the saturation is under a 50% threshold
// we just change to white mode instead.
@Override
public void setHSB(int hue, int saturation, int brightness, MilightThingState state) {
if (saturation < 50) {
whiteMode(state);
} else {
setPower(true, state);
state.hue360 = hue;
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_COLOR_SET),
new byte[] { 0x20, AbstractLedV3Handler.makeColor(hue), 0x55 }));
if (brightness != -1) {
setBrightness(brightness, state);
}
}
}
@Override
public void setPower(boolean on, MilightThingState state) {
if (on) {
logger.debug("milight: sendOn");
byte messageBytes[] = null;
// message rgb bulbs ON
messageBytes = new byte[] { 0x22, 0x00, 0x55 };
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE), messageBytes));
} else {
logger.debug("milight: sendOff");
byte messageBytes[] = null;
// message rgb bulbs OFF
messageBytes = new byte[] { 0x21, 0x00, 0x55 };
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE), messageBytes));
}
}
@Override
public void whiteMode(MilightThingState state) {
}
@Override
public void nightMode(MilightThingState state) {
}
@Override
public void changeColorTemperature(int colorTempRelative, MilightThingState state) {
}
@Override
public void setLedMode(int mode, MilightThingState state) {
}
@Override
public void setSaturation(int value, MilightThingState state) {
}
@Override
public void changeSaturation(int relativeSaturation, MilightThingState state) {
}
@Override
public void setColorTemperature(int colorTemp, MilightThingState state) {
}
@Override
public void setBrightness(int value, MilightThingState state) {
if (value <= 0) {
setPower(false, state);
return;
}
if (value > state.brightness) {
int repeatCount = (value - state.brightness) / 10;
for (int i = 0; i < repeatCount; i++) {
changeBrightness(+1, state);
}
} else if (value < state.brightness) {
int repeatCount = (state.brightness - value) / 10;
for (int i = 0; i < repeatCount; i++) {
changeBrightness(-1, state);
}
}
state.brightness = value;
}
@Override
public void changeBrightness(int relativeBrightness, MilightThingState state) {
int newPercent = state.brightness + relativeBrightness;
if (newPercent < 0) {
newPercent = 0;
}
if (newPercent > 100) {
newPercent = 100;
}
if (state.brightness != -1 && newPercent == 0) {
setPower(false, state);
} else {
setPower(true, state);
int steps = (int) Math.abs(Math.floor(relativeBrightness * BR_LEVELS / 100.0));
for (int s = 0; s < steps; ++s) {
byte[] t = { (byte) (relativeBrightness < 0 ? 0x24 : 0x23), 0x00, 0x55 };
sendQueue.queue(createNonRepeatable(t));
}
}
state.brightness = newPercent;
}
@Override
public void changeSpeed(int relativeSpeed, MilightThingState state) {
}
@Override
public void previousAnimationMode(MilightThingState state) {
setPower(true, state);
sendQueue.queue(createNonRepeatable(new byte[] { 0x28, 0x00, 0x55 }));
state.animationMode = Math.min(state.animationMode - 1, 0);
}
@Override
public void nextAnimationMode(MilightThingState state) {
setPower(true, state);
sendQueue.queue(createNonRepeatable(new byte[] { 0x27, 0x00, 0x55 }));
state.animationMode = Math.max(state.animationMode + 1, 100);
}
}

View File

@@ -0,0 +1,142 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.ProtocolConstants;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.Thing;
/**
* Implements functionality for the RGB/White bulbs for protocol version 3.
* Color temperature and saturation are not supported for this type of bulbs.
*
* @author David Graeff - Initial contribution
*/
public class MilightV3RGBWHandler extends AbstractLedV3Handler {
protected static final int BRIGHTNESS_LEVELS = 26;
private static final byte COMMAND_ON[] = { (byte) 0x42, (byte) 0x45, (byte) 0x47, (byte) 0x49, (byte) 0x4B };
private static final byte COMMAND_OFF[] = { (byte) 0x41, (byte) 0x46, (byte) 0x48, (byte) 0x4A, (byte) 0x4C };
private static final byte COMMAND_WHITEMODE[] = { (byte) 0xC2, (byte) 0xC5, (byte) 0xC7, (byte) 0xC9, (byte) 0xCB };
private static final byte NIGHTMODE_FIRST[] = { 0x41, 0x46, 0x48, 0x4A, 0x4C };
private static final byte NIGHTMODE_SECOND[] = { (byte) 0xC1, (byte) 0xC6, (byte) 0xC8, (byte) 0xCA, (byte) 0xCC };
private static final byte NEXT_ANIMATION_MODE[] = { 0x4D, 0x00, 0x55 };
public MilightV3RGBWHandler(Thing thing, QueuedSend sendQueue) {
super(thing, sendQueue, 10);
}
// We have no real saturation control for RGBW bulbs. If the saturation is under a 50% threshold
// we just change to white mode instead.
@Override
public void setHSB(int hue, int saturation, int brightness, MilightThingState state) {
if (saturation < 50) {
whiteMode(state);
} else {
state.hue360 = hue;
final byte messageBytes[] = new byte[] { 0x40, makeColor(hue), 0x55 };
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_COLOR_SET), cOn).addRepeatable(messageBytes));
}
if (brightness != -1) {
setBrightness(brightness, state);
}
}
@Override
public void setPower(boolean on, MilightThingState state) {
if (on) {
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE),
new byte[] { COMMAND_ON[config.zone], 0x00, 0x55 }));
} else {
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE),
new byte[] { COMMAND_OFF[config.zone], 0x00, 0x55 }));
}
}
@Override
public void whiteMode(MilightThingState state) {
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
final byte cWhite[] = { COMMAND_WHITEMODE[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_WHITEMODE), cOn).addRepeatable(cWhite));
}
@Override
public void nightMode(MilightThingState state) {
final byte cN1[] = { NIGHTMODE_FIRST[config.zone], 0x00, 0x55 };
final byte cN2[] = { NIGHTMODE_SECOND[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE), cN1).addRepeatable(cN2));
}
@Override
public void setColorTemperature(int colorTemp, MilightThingState state) {
}
@Override
public void changeColorTemperature(int colorTempRelative, MilightThingState state) {
}
@Override
public void setBrightness(int newvalue, MilightThingState state) {
int value = Math.min(Math.max(newvalue, 0), 100);
if (value == 0) {
state.brightness = value;
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE),
new byte[] { COMMAND_OFF[config.zone], 0x00, 0x55 }));
return;
}
int br = (int) Math.ceil((value * BRIGHTNESS_LEVELS) / 100.0) + 1;
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_BRIGHTNESS_SET), cOn)
.addRepeatable(new byte[] { 0x4E, (byte) br, 0x55 }));
state.brightness = value;
}
@Override
public void changeBrightness(int relativeBrightness, MilightThingState state) {
setBrightness(Math.max(0, Math.min(100, state.brightness + relativeBrightness)), state);
}
@Override
public void changeSpeed(int relativeSpeed, MilightThingState state) {
if (relativeSpeed == 0) {
return;
}
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
final byte cSpeed[] = { (byte) (relativeSpeed > 0 ? 0x44 : 0x43), 0x00, 0x55 };
sendQueue.queue(createRepeatable(cOn).addNonRepeatable(cSpeed));
}
// This bulb actually doesn't implement a previous animation mode command. We just use the next mode command
// instead.
@Override
public void previousAnimationMode(MilightThingState state) {
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(cOn).addNonRepeatable(NEXT_ANIMATION_MODE));
state.animationMode = (state.animationMode + 1) % (MAX_ANIM_MODES + 1);
}
@Override
public void nextAnimationMode(MilightThingState state) {
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(cOn).addNonRepeatable(NEXT_ANIMATION_MODE));
state.animationMode = (state.animationMode + 1) % (MAX_ANIM_MODES + 1);
}
}

View File

@@ -0,0 +1,208 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.ProtocolConstants;
import org.openhab.binding.milight.internal.protocol.QueueItem;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.Thing;
/**
* Implements functionality for the Dual White bulbs for protocol version 3.
* Color temperature is supported for this type of bulbs.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MilightV3WhiteHandler extends AbstractLedV3Handler {
protected static final int BRIGHTNESS_LEVELS = 11;
public MilightV3WhiteHandler(Thing thing, QueuedSend sendQueue) {
super(thing, sendQueue, 0);
}
private static final byte COMMAND_FULL[] = { (byte) 0xB5, (byte) 0xB8, (byte) 0xBD, (byte) 0xB7, (byte) 0xB2 };
private static final byte COMMAND_ON[] = { (byte) 0x35, (byte) 0x38, (byte) 0x3D, (byte) 0x37, (byte) 0x32 };
private static final byte COMMAND_OFF[] = { (byte) 0x39, (byte) 0x3B, (byte) 0x33, (byte) 0x3A, (byte) 0x36 };
private static final byte COMMAND_NIGHTMODE[] = { (byte) 0xB9, (byte) 0xBB, (byte) 0xB3, (byte) 0xBA, (byte) 0xB6 };
private static final byte PREV_ANIMATION_MODE[] = { 0x27, 0x00, 0x55 };
private static final byte NEXT_ANIMATION_MODE[] = { 0x27, 0x00, 0x55 };
protected void setFull(int zone, MilightThingState state) {
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_BRIGHTNESS_SET),
new byte[] { COMMAND_FULL[zone], 0x00, 0x55 }));
state.brightness = 100;
}
@Override
public void setHSB(int hue, int saturation, int brightness, MilightThingState state) {
// This bulb type only supports a brightness value
if (brightness != -1) {
setBrightness(brightness, state);
}
}
@Override
public void setPower(boolean on, MilightThingState state) {
if (on) {
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE),
new byte[] { COMMAND_ON[config.zone], 0x00, 0x55 }));
} else {
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE),
new byte[] { COMMAND_OFF[config.zone], 0x00, 0x55 }));
state.brightness = 0;
}
}
@Override
public void whiteMode(MilightThingState state) {
}
@Override
public void nightMode(MilightThingState state) {
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
final byte cNight[] = { COMMAND_NIGHTMODE[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE), cOn).addRepeatable(cNight));
}
@Override
public void setColorTemperature(int colorTemp, MilightThingState state) {
// White Bulbs: 11 levels of temperature + Off.
int newLevel;
int oldLevel;
// Reset bulb to known state
if (colorTemp <= 0) {
colorTemp = 0;
newLevel = 1;
oldLevel = BRIGHTNESS_LEVELS;
} else if (colorTemp >= 100) {
colorTemp = 100;
newLevel = BRIGHTNESS_LEVELS;
oldLevel = 1;
} else {
newLevel = (int) Math.ceil((colorTemp * BRIGHTNESS_LEVELS) / 100.0);
oldLevel = (int) Math.ceil((state.colorTemperature * BRIGHTNESS_LEVELS) / 100.0);
}
final int repeatCount = Math.abs(newLevel - oldLevel);
if (newLevel > oldLevel) {
for (int i = 0; i < repeatCount; i++) {
changeColorTemperature(1, state);
}
} else if (newLevel < oldLevel) {
for (int i = 0; i < repeatCount; i++) {
changeColorTemperature(-1, state);
}
}
state.colorTemperature = colorTemp;
}
@Override
public void changeColorTemperature(int colorTempRelative, MilightThingState state) {
state.colorTemperature = Math.min(100, Math.max(state.colorTemperature + colorTempRelative, 0));
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
final byte cTemp[] = { (byte) (colorTempRelative > 0 ? 0x3E : 0x3F), 0x00, 0x55 };
sendQueue.queue(createRepeatable(cOn).addNonRepeatable(cTemp));
}
// This just emulates an absolute brightness command with the relative commands.
@Override
public void setBrightness(int value, MilightThingState state) {
if (value <= 0) {
setPower(false, state);
return;
} else if (value >= 100) {
setFull(config.zone, state);
return;
}
// White Bulbs: 11 levels of brightness + Off.
final int newLevel = (int) Math.ceil((value * BRIGHTNESS_LEVELS) / 100.0);
// When turning on start from full brightness
int oldLevel;
final byte cFull[] = { COMMAND_FULL[config.zone], 0x00, 0x55 };
QueueItem item = createRepeatable(cFull);
boolean skipFirst = false;
if (state.brightness == 0) {
oldLevel = BRIGHTNESS_LEVELS;
} else {
oldLevel = (int) Math.ceil((state.brightness * BRIGHTNESS_LEVELS) / 100.0);
skipFirst = true;
if (newLevel == oldLevel) {
return;
}
}
final int repeatCount = Math.abs(newLevel - oldLevel);
logger.debug("milight: dim from '{}' with command '{}' via '{}' steps.", String.valueOf(state.brightness),
String.valueOf(value), repeatCount);
int op = newLevel > oldLevel ? +1 : -1;
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
for (int i = 0; i < repeatCount; i++) {
final byte[] cBr = { (byte) (op < 0 ? 0x34 : 0x3C), 0x00, 0x55 };
item = item.addRepeatable(cOn).addNonRepeatable(cBr);
}
final QueueItem nextItem = item.next;
if (nextItem != null && skipFirst) {
sendQueue.queue(nextItem);
} else {
sendQueue.queue(item);
}
state.brightness = value;
}
@Override
public void changeBrightness(int relativeBrightness, MilightThingState state) {
if (relativeBrightness == 0) {
return;
}
state.brightness = Math.min(Math.max(state.brightness + relativeBrightness, 0), 100);
if (state.brightness == 0) {
sendQueue.queue(createRepeatable(uidc(ProtocolConstants.CAT_POWER_MODE),
new byte[] { COMMAND_OFF[config.zone], 0x00, 0x55 }));
} else {
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
final byte cBr[] = { (byte) (relativeBrightness < 0 ? 0x34 : 0x3C), 0x00, 0x55 };
sendQueue.queue(createRepeatable(cOn).addNonRepeatable(cBr));
}
}
@Override
public void changeSpeed(int relativeSpeed, MilightThingState state) {
}
@Override
public void previousAnimationMode(MilightThingState state) {
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(cOn).addNonRepeatable(PREV_ANIMATION_MODE));
state.animationMode = Math.max(state.animationMode - 1, 0);
}
@Override
public void nextAnimationMode(MilightThingState state) {
final byte cOn[] = { COMMAND_ON[config.zone], 0x00, 0x55 };
sendQueue.queue(createRepeatable(cOn).addNonRepeatable(NEXT_ANIMATION_MODE));
state.animationMode = Math.min(state.animationMode + 1, MAX_ANIM_MODES);
}
}

View File

@@ -0,0 +1,89 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.ProtocolConstants;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.Thing;
/**
* Implements the RGB cold white / warm white bulb. It is the most feature rich bulb.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MilightV6RGBCWWWHandler extends AbstractLedV6Handler {
private static final int ADDR = 0x08;
public MilightV6RGBCWWWHandler(Thing thing, QueuedSend sendQueue) {
super(thing, sendQueue, 10);
}
@Override
protected byte getAddr() {
return ADDR;
}
@Override
public void setPower(boolean on, MilightThingState state) {
sendRepeatableCat(ProtocolConstants.CAT_POWER_MODE, 4, on ? 1 : 2);
}
@Override
public void whiteMode(MilightThingState state) {
sendRepeatableCat(ProtocolConstants.CAT_WHITEMODE, 5, state.colorTemperature);
}
@Override
public void nightMode(MilightThingState state) {
sendRepeatableCat(ProtocolConstants.CAT_POWER_MODE, 4, 5);
}
@Override
public void setColorTemperature(int colorTemp, MilightThingState state) {
int ct = (colorTemp * MAX_TEMP) / 100;
ct = Math.min(ct, MAX_TEMP);
ct = Math.max(ct, 0);
sendRepeatableCat(ProtocolConstants.CAT_TEMPERATURE_SET, 5, ct);
state.colorTemperature = colorTemp;
}
@Override
protected byte getBrCmd() {
return 3;
}
@Override
public void setSaturation(int value, MilightThingState state) {
int br = (value * MAX_SAT) / 100; // map value from [0,100] -> [0,MAX_SAT]
br = MAX_SAT - br; // inverse value
br = Math.min(br, MAX_SAT); // force maximum value
br = Math.max(br, 0); // force minimum value
sendRepeatableCat(ProtocolConstants.CAT_SATURATION_SET, 2, br);
state.saturation = value;
}
@Override
public void setLedMode(int newmode, MilightThingState state) {
int mode = Math.max(Math.min(newmode, 9), 1);
sendRepeatableCat(ProtocolConstants.CAT_MODE_SET, 6, mode);
state.animationMode = mode;
}
@Override
public void changeSpeed(int relativeSpeed, MilightThingState state) {
sendNonRepeatable(4, relativeSpeed > 1 ? 3 : 4);
}
}

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.ProtocolConstants;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.Thing;
/**
* Implements the iBox led bulb. The bulb is integrated into the iBox and does not include a white channel, so no
* saturation or colour temperature available.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MilightV6RGBIBOXHandler extends AbstractLedV6Handler {
private static final byte ADDR = 0x00;
public MilightV6RGBIBOXHandler(Thing thing, QueuedSend sendQueue) {
super(thing, sendQueue, 0);
}
@Override
protected byte getAddr() {
return ADDR;
}
@Override
public void setPower(boolean on, MilightThingState state) {
sendNonRepeatable(3, on ? 3 : 4);
}
@Override
public void whiteMode(MilightThingState state) {
sendRepeatableCat(ProtocolConstants.CAT_WHITEMODE, 3, 5);
}
@Override
public void nightMode(MilightThingState state) {
logger.info("Night mode not supported by iBox led!");
}
@Override
public void setColorTemperature(int colorTemp, MilightThingState state) {
logger.info("Color temperature not supported by iBox led!");
}
@Override
public void changeColorTemperature(int colorTempRelative, MilightThingState state) {
logger.info("Color temperature not supported by iBox led!");
}
@Override
protected byte getBrCmd() {
return 2;
}
@Override
public void setSaturation(int value, MilightThingState state) {
logger.info("Saturation not supported by iBox led!");
}
@Override
public void setLedMode(int newmode, MilightThingState state) {
int mode = Math.max(Math.min(newmode, 9), 1);
sendRepeatableCat(ProtocolConstants.CAT_MODE_SET, 4, Math.max(Math.min(newmode, 9), 1));
state.animationMode = mode;
}
@Override
public void changeSpeed(int relativeSpeed, MilightThingState state) {
sendNonRepeatable(3, relativeSpeed > 1 ? 2 : 1);
}
}

View File

@@ -0,0 +1,82 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.milight.internal.MilightThingState;
import org.openhab.binding.milight.internal.protocol.ProtocolConstants;
import org.openhab.binding.milight.internal.protocol.QueuedSend;
import org.openhab.core.thing.Thing;
/**
* Implements the RGB white bulb. Both leds cannot be on at the same time, so no saturation or colour temperature
* control. It still allows more colours than the old v3 rgbw bulb (16320 (255*64) vs 4080 (255*16) colors).
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MilightV6RGBWHandler extends AbstractLedV6Handler {
private static final int ADDR = 0x07;
public MilightV6RGBWHandler(Thing thing, QueuedSend sendQueue) {
super(thing, sendQueue, 20);
}
@Override
protected byte getAddr() {
return ADDR;
}
@Override
public void setPower(boolean on, MilightThingState state) {
sendNonRepeatable(3, on ? 1 : 2);
}
@Override
public void whiteMode(MilightThingState state) {
sendRepeatableCat(ProtocolConstants.CAT_WHITEMODE, 3, 5);
}
@Override
public void nightMode(MilightThingState state) {
setPower(true, state);
sendRepeatableCat(ProtocolConstants.CAT_POWER_MODE, 3, 6);
}
@Override
public void setColorTemperature(int colorTemp, MilightThingState state) {
logger.info("Color temperature not supported by RGBW led!");
}
@Override
protected byte getBrCmd() {
return 2;
}
@Override
public void setSaturation(int value, MilightThingState state) {
logger.info("Saturation not supported by RGBW led!");
}
@Override
public void setLedMode(int newmode, MilightThingState state) {
int mode = Math.max(Math.min(newmode, 9), 1);
sendRepeatableCat(ProtocolConstants.CAT_MODE_SET, 6, mode);
state.animationMode = mode;
}
@Override
public void changeSpeed(int relativeSpeed, MilightThingState state) {
sendNonRepeatable(4, relativeSpeed > 1 ? 3 : 4);
}
}

View File

@@ -0,0 +1,694 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.protocol;
import java.io.Closeable;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.time.Instant;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The milightV6 protocol is stateful and needs an established session for each client.
* This class handles the password bytes, session bytes and sequence number.
*
* The session handshake is a 3-way handshake. First we are sending either a general
* search for bridges command or a search for a specific bridge command (containing the bridge ID)
* with our own client session bytes included.
*
* The response will assign as session bytes that we can use for subsequent commands
* see {@link MilightV6SessionManager#sid1} and see {@link MilightV6SessionManager#sid2}.
*
* We register ourself to the bridge now and finalise the handshake by sending a register command
* see {@link MilightV6SessionManager#sendRegistration()} to the bridge.
*
* From this point on we are required to send keep alive packets to the bridge every ~10sec
* to keep the session alive. Because each command we send is confirmed by the bridge, we know if
* our session is still valid and can redo the session handshake if necessary.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MilightV6SessionManager implements Runnable, Closeable {
protected final Logger logger = LoggerFactory.getLogger(MilightV6SessionManager.class);
// The used sequence number for a command will be present in the response of the iBox. This
// allows us to identify failed command deliveries.
private int sequenceNo = 0;
// Password bytes 1 and 2
public byte pw[] = { 0, 0 };
// Session bytes 1 and 2
public byte sid[] = { 0, 0 };
// Client session bytes 1 and 2. Those are fixed for now.
public final byte clientSID1 = (byte) 0xab;
public final byte clientSID2 = (byte) 0xde;
// We need the bridge mac (bridge ID) in many responses to the session commands.
private final byte[] bridgeMAC = { (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0 };
/**
* The session handshake is a 3 way handshake.
*/
public enum SessionState {
// No session established and nothing in progress
SESSION_INVALID,
// Send "find bridge" and wait for response
SESSION_WAIT_FOR_BRIDGE,
// Send "get session bytes" and wait for response
SESSION_WAIT_FOR_SESSION_SID,
// Session bytes received, register session now
SESSION_NEED_REGISTER,
// Registration complete, session is valid now
SESSION_VALID,
// The session is still active, a keep alive was just received.
SESSION_VALID_KEEP_ALIVE,
}
public enum StateMachineInput {
NO_INPUT,
TIMEOUT,
INVALID_COMMAND,
KEEP_ALIVE_RECEIVED,
BRIDGE_CONFIRMED,
SESSION_ID_RECEIVED,
SESSION_ESTABLISHED,
}
private SessionState sessionState = SessionState.SESSION_INVALID;
// Implement this interface to get notifications about the current session state.
public interface ISessionState {
/**
* Notifies about a state change of {@link MilightV6SessionManager}.
* SESSION_VALID_KEEP_ALIVE will be reported in the interval, given to the constructor of
* {@link MilightV6SessionManager}.
*
* @param state The new state
* @param address The remote IP address. Only guaranteed to be non null in the SESSION_VALID* states.
*/
void sessionStateChanged(SessionState state, @Nullable InetAddress address);
}
private final ISessionState observer;
/** Used to determine if the session needs a refresh */
private Instant lastSessionConfirmed = Instant.now();
/** Quits the receive thread if set to true */
private volatile boolean willbeclosed = false;
/** Keep track of send commands and their sequence number */
private final Map<Integer, Instant> usedSequenceNo = new TreeMap<>();
/** The receive thread for all bridge responses. */
private final Thread sessionThread;
private final String bridgeId;
private @Nullable DatagramSocket datagramSocket;
private @Nullable CompletableFuture<DatagramSocket> startFuture;
/**
* Usually we only send BROADCAST packets. If we know the IP address of the bridge though,
* we should try UNICAST packets before falling back to BROADCAST.
* This allows communication with the bridge even if it is in another subnet.
*/
private @Nullable final InetAddress destIP;
/**
* We cache the last known IP to avoid using broadcast.
*/
private @Nullable InetAddress lastKnownIP;
private final int port;
/** The maximum duration for a session registration / keep alive process in milliseconds. */
public static final int TIMEOUT_MS = 10000;
/** A packet is handled as lost / not confirmed after this time */
public static final int MAX_PACKET_IN_FLIGHT_MS = 2000;
/** The keep alive interval. Must be between 100 and REG_TIMEOUT_MS milliseconds or 0 */
private final int keepAliveInterval;
/**
* A session manager for the V6 bridge needs a way to send data (a QueuedSend object), the destination bridge ID, a
* scheduler for timeout timers and optionally an observer for session state changes.
*
* @param sendQueue A send queue. Never remove or change that object while the session manager is still working.
* @param bridgeId Destination bridge ID. If the bridge ID for whatever reason changes, you need to create a new
* session manager object
* @param scheduler A framework scheduler to create timeout events.
* @param observer Get notifications of state changes
* @param destIP If you know the bridge IP address, provide it here.
* @param port The bridge port
* @param keepAliveInterval The keep alive interval. Must be between 100 and REG_TIMEOUT_MS milliseconds.
* if it is equal to REG_TIMEOUT_MS, then a new session will be established instead of renewing the
* current one.
* @param pw The two "password" bytes for the bridge
*/
public MilightV6SessionManager(String bridgeId, ISessionState observer, @Nullable InetAddress destIP, int port,
int keepAliveInterval, byte[] pw) {
this.bridgeId = bridgeId;
this.observer = observer;
this.destIP = destIP;
this.lastKnownIP = destIP;
this.port = port;
this.keepAliveInterval = keepAliveInterval;
this.pw[0] = pw[0];
this.pw[1] = pw[1];
for (int i = 0; i < 6; ++i) {
bridgeMAC[i] = Integer.valueOf(bridgeId.substring(i * 2, i * 2 + 2), 16).byteValue();
}
if (keepAliveInterval < 100 || keepAliveInterval > TIMEOUT_MS) {
throw new IllegalArgumentException("keepAliveInterval not within given limits!");
}
sessionThread = new Thread(this, "SessionThread");
}
/**
* Start the session thread if it is not already running
*/
public CompletableFuture<DatagramSocket> start() {
if (willbeclosed) {
CompletableFuture<DatagramSocket> f = new CompletableFuture<>();
f.completeExceptionally(null);
return f;
}
if (sessionThread.isAlive()) {
DatagramSocket s = datagramSocket;
assert s != null;
return CompletableFuture.completedFuture(s);
}
CompletableFuture<DatagramSocket> f = new CompletableFuture<>();
startFuture = f;
sessionThread.start();
return f;
}
/**
* You have to call that if you are done with this object. Cleans up the receive thread.
*/
@Override
public void close() throws IOException {
if (willbeclosed) {
return;
}
willbeclosed = true;
final DatagramSocket socket = datagramSocket;
if (socket != null) {
socket.close();
}
sessionThread.interrupt();
try {
sessionThread.join();
} catch (InterruptedException e) {
}
}
// Set the session id bytes for bridge access. Usually they are acquired automatically
// during the session handshake.
public void setSessionID(byte[] sid) {
this.sid[0] = sid[0];
this.sid[1] = sid[1];
sessionState = SessionState.SESSION_NEED_REGISTER;
}
// Return the session bytes as hex string
public String getSession() {
return String.format("%02X %02X", this.sid[0], this.sid[1]);
}
public Instant getLastSessionValidConfirmation() {
return lastSessionConfirmed;
}
// Get a new sequence number. Add that to a queue of used sequence numbers.
// The bridge response will remove the queued number. This method also checks
// for non confirmed sequence numbers older that 2 seconds and report them.
public int getNextSequenceNo() {
int currentSequenceNo = this.sequenceNo;
usedSequenceNo.put(currentSequenceNo, Instant.now());
++sequenceNo;
return currentSequenceNo;
}
public static byte firstSeqByte(int seq) {
return (byte) (seq & 0xff);
}
public static byte secondSeqByte(int seq) {
return (byte) ((seq >> 8) & 0xff);
}
/**
* Send a search for bridgeID packet on all network interfaces.
* This is used for the initial way to determine the IP of the bridge as well
* as if the IP of a bridge has changed and the session got invalid because of that.
*
* A response will assign us session bytes.
*
* @throws InterruptedException
*/
@SuppressWarnings({ "null", "unused" })
private void sendSearchForBroadcast(DatagramSocket datagramSocket) {
byte[] t = new byte[] { (byte) 0x10, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x0A, (byte) 0x02,
clientSID1, clientSID2, (byte) 0x01, bridgeMAC[0], bridgeMAC[1], bridgeMAC[2], bridgeMAC[3],
bridgeMAC[4], bridgeMAC[5] };
if (lastKnownIP != null) {
try {
datagramSocket.send(new DatagramPacket(t, t.length, lastKnownIP, port));
} catch (IOException e) {
logger.warn("Could not send discover packet! {}", e.getLocalizedMessage());
}
return;
}
Enumeration<NetworkInterface> enumNetworkInterfaces;
try {
enumNetworkInterfaces = NetworkInterface.getNetworkInterfaces();
} catch (SocketException socketException) {
logger.warn("Could not enumerate network interfaces for sending the discover packet!", socketException);
return;
}
DatagramPacket packet = new DatagramPacket(t, t.length, lastKnownIP, port);
while (enumNetworkInterfaces.hasMoreElements()) {
NetworkInterface networkInterface = enumNetworkInterfaces.nextElement();
Iterator<InterfaceAddress> it = networkInterface.getInterfaceAddresses().iterator();
while (it.hasNext()) {
InterfaceAddress address = it.next();
if (address == null) {
continue;
}
InetAddress broadcast = address.getBroadcast();
if (broadcast != null && !address.getAddress().isLoopbackAddress()) {
packet.setAddress(broadcast);
try {
datagramSocket.send(packet);
} catch (IOException e) {
logger.warn("Could not send discovery packet! {}", e.getLocalizedMessage());
}
}
}
}
}
// Search for a specific bridge (our bridge). A response will assign us session bytes.
// private void send_search_for() {
// sendQueue.queue(AbstractBulbInterface.CAT_SESSION, searchForPacket());
// }
private void sendEstablishSession(DatagramSocket datagramSocket) throws IOException {
final InetAddress address = lastKnownIP;
if (address == null) {
return;
}
byte unknown = (byte) 0x1E; // Either checksum or counter. Was 64 and 1e so far.
byte[] t = { (byte) 0x20, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x16, (byte) 0x02, (byte) 0x62,
(byte) 0x3A, (byte) 0xD5, (byte) 0xED, (byte) 0xA3, (byte) 0x01, (byte) 0xAE, (byte) 0x08, (byte) 0x2D,
(byte) 0x46, (byte) 0x61, (byte) 0x41, (byte) 0xA7, (byte) 0xF6, (byte) 0xDC, (byte) 0xAF, clientSID1,
clientSID2, (byte) 0x00, (byte) 0x00, unknown };
datagramSocket.send(new DatagramPacket(t, t.length, address, port));
}
// Some apps first send {@see send_establish_session} and with the aquired session bytes they
// subsequently send this command for establishing the session. This is not well documented unfortunately.
@SuppressWarnings("unused")
private void sendPreRegistration(DatagramSocket datagramSocket) throws IOException {
final InetAddress address = lastKnownIP;
if (address == null) {
return;
}
byte[] t = { 0x30, 0, 0, 0, 3, sid[0], sid[1], 1, 0 };
datagramSocket.send(new DatagramPacket(t, t.length, address, port));
}
// After the bridges knows our client session bytes and we know the bridge session bytes, we do a final
// registration with this command. The response will again contain the bridge ID and the session should
// be established by then.
private void sendRegistration(DatagramSocket datagramSocket) throws IOException {
final InetAddress address = lastKnownIP;
if (address == null) {
return;
}
int seq = getNextSequenceNo();
byte[] t = { (byte) 0x80, 0x00, 0x00, 0x00, 0x11, sid[0], sid[1], firstSeqByte(seq), secondSeqByte(seq), 0x00,
0x33, pw[0], pw[1], 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, (byte) (0x33 + pw[0] + pw[1]) };
datagramSocket.send(new DatagramPacket(t, t.length, address, port));
}
/**
* Constructs a 0x80... command which us used for all colour,brightness,saturation,mode operations.
* The session ID, password and sequence number is automatically inserted from this object.
*
* Produces data like:
* SN: Sequence number
* S1: SessionID1
* S2: SessionID2
* P1/P2: Password bytes
* WB: Remote (08) or iBox integrated bulb (00)
* ZN: Zone {Zone1-4 0=All}
* CK: Checksum
*
* #zone 1 on
* @ 80 00 00 00 11 84 00 00 0c 00 31 00 00 08 04 01 00 00 00 01 00 3f
*
* Colors:
* CC: Color value (hue)
* 80 00 00 00 11 S1 S2 SN SN 00 31 P1 P2 WB 01 CC CC CC CC ZN 00 CK
*
* 80 00 00 00 11 D4 00 00 12 00 31 00 00 08 01 FF FF FF FF 01 00 38
*
* @return
*/
public byte[] makeCommand(byte wb, int zone, int... data) {
int seq = getNextSequenceNo();
byte[] t = { (byte) 0x80, 0x00, 0x00, 0x00, 0x11, sid[0], sid[1], MilightV6SessionManager.firstSeqByte(seq),
MilightV6SessionManager.secondSeqByte(seq), 0x00, 0x31, pw[0], pw[1], wb, 0, 0, 0, 0, 0, (byte) zone, 0,
0 };
for (int i = 0; i < data.length; ++i) {
t[14 + i] = (byte) data[i];
}
byte chksum = (byte) (t[10 + 0] + t[10 + 1] + t[10 + 2] + t[10 + 3] + t[10 + 4] + t[10 + 5] + t[10 + 6]
+ t[10 + 7] + t[10 + 8] + zone);
t[21] = chksum;
return t;
}
/**
* Constructs a 0x3D or 0x3E link/unlink command.
* The session ID, password and sequence number is automatically inserted from this object.
*
* WB: Remote (08) or iBox integrated bulb (00)
*/
public byte[] makeLink(byte wb, int zone, boolean link) {
int seq = getNextSequenceNo();
byte[] t = { (link ? (byte) 0x3D : (byte) 0x3E), 0x00, 0x00, 0x00, 0x11, sid[0], sid[1],
MilightV6SessionManager.firstSeqByte(seq), MilightV6SessionManager.secondSeqByte(seq), 0x00, 0x31,
pw[0], pw[1], wb, 0x00, 0x00, 0x00, 0x00, 0x00, (byte) zone, 0x00, 0x00 };
byte chksum = (byte) (t[10 + 0] + t[10 + 1] + t[10 + 2] + t[10 + 3] + t[10 + 4] + t[10 + 5] + t[10 + 6]
+ t[10 + 7] + t[10 + 8] + zone);
t[21] = chksum;
return t;
}
/**
* The main state machine of the session handshake.
*
* @throws InterruptedException
* @throws IOException
*/
private void sessionStateMachine(DatagramSocket datagramSocket, StateMachineInput input) throws IOException {
final SessionState lastSessionState = sessionState;
// Check for timeout
final Instant current = Instant.now();
final Duration timeElapsed = Duration.between(lastSessionConfirmed, current);
if (timeElapsed.toMillis() > TIMEOUT_MS) {
if (sessionState != SessionState.SESSION_WAIT_FOR_BRIDGE) {
logger.warn("Session timeout!");
}
// One reason we failed, might be that a last known IP is not correct anymore.
// Reset to the given dest IP (which might be null).
lastKnownIP = destIP;
sessionState = SessionState.SESSION_INVALID;
}
if (input == StateMachineInput.INVALID_COMMAND) {
sessionState = SessionState.SESSION_INVALID;
}
// Check old seq no:
for (Iterator<Map.Entry<Integer, Instant>> it = usedSequenceNo.entrySet().iterator(); it.hasNext();) {
Map.Entry<Integer, Instant> entry = it.next();
if (Duration.between(entry.getValue(), current).toMillis() > MAX_PACKET_IN_FLIGHT_MS) {
logger.debug("Command not confirmed: {}", entry.getKey());
it.remove();
}
}
switch (sessionState) {
case SESSION_INVALID:
usedSequenceNo.clear();
sessionState = SessionState.SESSION_WAIT_FOR_BRIDGE;
lastSessionConfirmed = Instant.now();
case SESSION_WAIT_FOR_BRIDGE:
if (input == StateMachineInput.BRIDGE_CONFIRMED) {
sessionState = SessionState.SESSION_WAIT_FOR_SESSION_SID;
} else {
datagramSocket.setSoTimeout(150);
sendSearchForBroadcast(datagramSocket);
break;
}
case SESSION_WAIT_FOR_SESSION_SID:
if (input == StateMachineInput.SESSION_ID_RECEIVED) {
if (ProtocolConstants.DEBUG_SESSION) {
logger.debug("Session ID received: {}", String.format("%02X %02X", this.sid[0], this.sid[1]));
}
sessionState = SessionState.SESSION_NEED_REGISTER;
} else {
datagramSocket.setSoTimeout(300);
sendEstablishSession(datagramSocket);
break;
}
case SESSION_NEED_REGISTER:
if (input == StateMachineInput.SESSION_ESTABLISHED) {
sessionState = SessionState.SESSION_VALID;
lastSessionConfirmed = Instant.now();
if (ProtocolConstants.DEBUG_SESSION) {
logger.debug("Registration complete");
}
} else {
datagramSocket.setSoTimeout(300);
sendRegistration(datagramSocket);
break;
}
case SESSION_VALID_KEEP_ALIVE:
case SESSION_VALID:
if (input == StateMachineInput.KEEP_ALIVE_RECEIVED) {
lastSessionConfirmed = Instant.now();
observer.sessionStateChanged(SessionState.SESSION_VALID_KEEP_ALIVE, lastKnownIP);
} else {
final InetAddress address = lastKnownIP;
if (keepAliveInterval > 0 && timeElapsed.toMillis() > keepAliveInterval && address != null) {
// Send keep alive
byte[] t = { (byte) 0xD0, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x02, sid[0], sid[1] };
datagramSocket.send(new DatagramPacket(t, t.length, address, port));
}
// Increase socket timeout to wake up for the next keep alive interval
datagramSocket.setSoTimeout(keepAliveInterval);
}
break;
}
if (lastSessionState != sessionState) {
observer.sessionStateChanged(sessionState, lastKnownIP);
}
}
private void logUnknownPacket(byte[] data, int len, String reason) {
StringBuilder s = new StringBuilder();
for (int i = 0; i < len; ++i) {
s.append(String.format("%02X ", data[i]));
}
s.append("Sid: ");
s.append(String.format("%02X ", clientSID1));
s.append(String.format("%02X ", clientSID2));
logger.info("{} ({}): {}", reason, bridgeId, s);
}
/**
* The session thread executes this run() method and a blocking UDP receive
* is performed in a loop.
*/
@SuppressWarnings({ "null", "unused" })
@Override
public void run() {
try (DatagramSocket datagramSocket = new DatagramSocket(null)) {
this.datagramSocket = datagramSocket;
datagramSocket.setBroadcast(true);
datagramSocket.setReuseAddress(true);
datagramSocket.setSoTimeout(150);
datagramSocket.bind(null);
if (ProtocolConstants.DEBUG_SESSION) {
logger.debug("MilightCommunicationV6 receive thread ready");
}
// Inform the start future about the datagram socket
CompletableFuture<DatagramSocket> f = startFuture;
if (f != null) {
f.complete(datagramSocket);
startFuture = null;
}
byte[] buffer = new byte[1024];
DatagramPacket rPacket = new DatagramPacket(buffer, buffer.length);
sessionStateMachine(datagramSocket, StateMachineInput.NO_INPUT);
// Now loop forever, waiting to receive packets and printing them.
while (!willbeclosed) {
rPacket.setLength(buffer.length);
try {
datagramSocket.receive(rPacket);
} catch (SocketTimeoutException e) {
sessionStateMachine(datagramSocket, StateMachineInput.TIMEOUT);
continue;
}
int len = rPacket.getLength();
if (len < 5 || buffer[1] != 0 || buffer[2] != 0 || buffer[3] != 0) {
logUnknownPacket(buffer, len, "Not an iBox response!");
continue;
}
int expectedLen = buffer[4] + 5;
if (expectedLen > len) {
logUnknownPacket(buffer, len, "Unexpected size!");
continue;
}
switch (buffer[0]) {
// 13 00 00 00 0A 03 D3 54 11 (AC CF 23 F5 7A D4)
case (byte) 0x13: {
boolean eq = ByteBuffer.wrap(bridgeMAC, 0, 6).equals(ByteBuffer.wrap(buffer, 9, 6));
if (eq) {
logger.debug("TODO: Feedback required");
// I have no clue what that packet means. But the bridge is going to timeout the next
// keep alive and it is a good idea to start the session again.
} else {
logger.info("Unknown 0x13 received, but not for our bridge ({})", bridgeId);
}
break;
}
// 18 00 00 00 40 02 (AC CF 23 F5 7A D4) 00 20 39 38 35 62 31 35 37 62 66 36 66 63 34 33 33 36 38 61
// 36 33 34 36 37 65 61 33 62 31 39 64 30 64 01 00 01 17 63 00 00 05 00 09 78 6C 69 6E 6B 5F 64 65
// 76 07 5B CD 15
// ASCII string contained: 985b157bf6fc43368a63467ea3b19d0dc .. xlink_dev
// Response to the v6 SEARCH and the SEARCH FOR commands to look for new or known devices.
// Our session id will be transfered in this process (!= bridge session id)
case (byte) 0x18: {
boolean eq = ByteBuffer.wrap(bridgeMAC, 0, 6).equals(ByteBuffer.wrap(buffer, 6, 6));
if (eq) {
if (ProtocolConstants.DEBUG_SESSION) {
logger.debug("Session ID reestablished");
}
lastKnownIP = rPacket.getAddress();
sessionStateMachine(datagramSocket, StateMachineInput.BRIDGE_CONFIRMED);
} else {
logger.info("Session ID received, but not for our bridge ({})", bridgeId);
logUnknownPacket(buffer, len, "ID not matching");
}
break;
}
// 28 00 00 00 11 00 02 (AC CF 23 F5 7A D4) 50 AA 4D 2A 00 01 SS_ID 00
// Response to the keepAlive() packet if session is not valid yet.
// Should contain the session ids
case (byte) 0x28: {
boolean eq = ByteBuffer.wrap(bridgeMAC, 0, 6).equals(ByteBuffer.wrap(buffer, 7, 6));
if (eq) {
this.sid[0] = buffer[19];
this.sid[1] = buffer[20];
sessionStateMachine(datagramSocket, StateMachineInput.SESSION_ID_RECEIVED);
} else {
logger.info("Session ID received, but not for our bridge ({})", bridgeId);
logUnknownPacket(buffer, len, "ID not matching");
}
break;
}
// 80 00 00 00 15 (AC CF 23 F5 7A D4) 05 02 00 34 00 00 00 00 00 00 00 00 00 00 34
// Response to the registration packet
case (byte) 0x80: {
boolean eq = ByteBuffer.wrap(bridgeMAC, 0, 6).equals(ByteBuffer.wrap(buffer, 5, 6));
if (eq) {
sessionStateMachine(datagramSocket, StateMachineInput.SESSION_ESTABLISHED);
} else {
logger.info("Registration received, but not for our bridge ({})", bridgeId);
logUnknownPacket(buffer, len, "ID not matching");
}
break;
}
// 88 00 00 00 03 SN SN OK // two byte sequence number, we use the later one only.
// OK: is 00 if ok or 01 if failed
case (byte) 0x88:
int seq = Byte.toUnsignedInt(buffer[6]) + Byte.toUnsignedInt(buffer[7]) * 256;
Instant timePacketWasSend = usedSequenceNo.remove(seq);
if (timePacketWasSend != null) {
if (ProtocolConstants.DEBUG_SESSION) {
logger.debug("Confirmation received for command: {}", String.valueOf(seq));
}
if (buffer[8] == 1) {
logger.warn("Command {} failed", seq);
}
} else {
// another participant might have established a session from the same host
logger.info("Confirmation received for unsend command. Sequence number: {}",
String.valueOf(seq));
}
break;
// D8 00 00 00 07 (AC CF 23 F5 7A D4) 01
// Response to the keepAlive() packet
case (byte) 0xD8: {
boolean eq = ByteBuffer.wrap(bridgeMAC, 0, 6).equals(ByteBuffer.wrap(buffer, 5, 6));
if (eq) {
sessionStateMachine(datagramSocket, StateMachineInput.KEEP_ALIVE_RECEIVED);
} else {
logger.info("Keep alive received but not for our bridge ({})", bridgeId);
logUnknownPacket(buffer, len, "ID not matching");
}
break;
}
default:
logUnknownPacket(buffer, len, "No valid start byte");
}
}
} catch (IOException e) {
if (!willbeclosed) {
logger.warn("Session Manager receive thread failed: {}", e.getLocalizedMessage(), e);
}
} finally {
this.datagramSocket = null;
}
if (ProtocolConstants.DEBUG_SESSION) {
logger.debug("MilightCommunicationV6 receive thread stopped");
}
}
// Return true if the session is established successfully
public boolean isValid() {
return sessionState == SessionState.SESSION_VALID;
}
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.protocol;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Implement this bulb interface for each new bulb type. It is used by {@see MilightLedHandler} to handle commands.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public abstract class ProtocolConstants {
// Print out a lot of useful debug data for the session establishing
// This is for development purposes only, if the protocol changes again.
public static final boolean DEBUG_SESSION = true;
/**
* There can only be one command of a category in the send queue (to avoid
* having multiple on/off commands in the queue for example). You can assign
* a category to each command you send and use one of the following constants.
*/
// Session commands
public static final int CAT_DISCOVER = 1;
// Bulb commands
public static final int CAT_BRIGHTNESS_SET = 10;
public static final int CAT_SATURATION_SET = 11;
public static final int CAT_COLOR_SET = 12;
public static final int CAT_POWER_MODE = 13;
public static final int CAT_TEMPERATURE_SET = 14;
public static final int CAT_WHITEMODE = 17;
public static final int CAT_MODE_SET = 18;
public static final int CAT_SPEED_CHANGE = 19;
public static final int CAT_LINK = 20;
}

View File

@@ -0,0 +1,197 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.protocol;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* A queue item meant to be used for {@link QueuedSend}.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class QueueItem {
protected final int uniqueCommandId;
protected final boolean repeatable;
protected final int delayTime;
protected final int repeatCommands;
protected final DatagramPacket packet;
private boolean invalid = false;
final DatagramSocket socket;
private final QueueItem root;
private @Nullable QueueItem last;
public @Nullable QueueItem next;
/**
* Add data to the send queue.
* Commands which need to be queued up and not replacing same type commands must be non-categorised.
* Items can be repeatable, in the sense that they do not cause side effects if they are send multiple times
* (e.g. absolute values).
*
* @param socket A socket
* @param uniqueCommandId A unique command id. A later command with the same id as previous ones will
* overwrite those. 0 means a non categorised entry.
* @param data Data to be send
* @param repeatable A repeatable command should not cause side effects by sending it multiple times.
* @param delayTime A delay time that is used instead of the default delay time between commands for all
* added byte sequences.
*/
public QueueItem(DatagramSocket socket, int uniqueCommandId, byte[] data, boolean repeatable, int delayTime,
int repeatCommands, InetAddress address, int port) {
this.socket = socket;
this.uniqueCommandId = uniqueCommandId;
this.repeatable = repeatable;
this.delayTime = delayTime;
this.repeatCommands = repeatCommands;
this.root = this;
this.packet = new DatagramPacket(data, data.length, address, port);
}
/**
* @see #QueueItem(int, byte[], boolean, int)
* @param uniqueCommandId A unique command id. A later command with the same id as previous ones will
* overwrite those. 0 means a non categorised entry.
* @param data Data to be send
* @param repeatable A repeatable command should not cause side effects by sending it multiple times.
* @param customDelayTime A delay time that is used instead of the default delay time between commands for all
* added byte sequences.
* @param root Another item is the root entry for this one.
*/
private QueueItem(DatagramSocket socket, int uniqueCommandId, byte[] data, boolean repeatable, int customDelayTime,
int repeatCommands, InetAddress address, int port, QueueItem root) {
this.socket = socket;
this.uniqueCommandId = uniqueCommandId;
this.repeatable = repeatable;
this.delayTime = customDelayTime;
this.repeatCommands = repeatCommands;
this.root = root;
this.packet = new DatagramPacket(data, data.length, address, port);
}
/**
* Add non-categorised, repeatable data to the send queue.
*
* <p>
* Commands which need to be queued up and not replacing same type commands must use this method.
* Items added to the queue are considered not repeatable (suited for relative commands where a repetition would
* cause a change of value).
* </p>
*
* <p>
* If you want to, you can add multiple byte sequences to the queue, even if they have the same id.
* This is used for animations and multi-message commands. Be aware that a later added command with the
* same id will replace all those commands at once.
* </p>
*
* @param data Data to be send
*/
public static QueueItem createRepeatable(DatagramSocket socket, int customDelayTime, int repeatCommands,
InetAddress address, int port, byte[]... data) {
QueueItem item = new QueueItem(socket, QueuedSend.NO_CATEGORY, data[0], true, customDelayTime, repeatCommands,
address, port);
QueueItem next = item;
for (int i = 1; i < data.length; ++i) {
next = next.addRepeatable(data[i]);
}
return item;
}
/**
* Add non-categorised, non-repeatable data to the send queue.
*
* <p>
* Commands which need to be queued up and not replacing same type commands must use this method.
* Items added to the queue are considered not repeatable (suited for relative commands where a repetition would
* cause a change of value).
* </p>
*
* <p>
* If you want to, you can add multiple byte sequences to the queue, even if they have the same id.
* This is used for animations and multi-message commands. Be aware that a later added command with the
* same id will replace all those commands at once.
* </p>
*
* @param data Data to be send
*/
public static QueueItem createNonRepeatable(DatagramSocket socket, int customDelayTime, InetAddress address,
int port, byte[]... data) {
QueueItem item = new QueueItem(socket, QueuedSend.NO_CATEGORY, data[0], false, customDelayTime, 1, address,
port);
QueueItem next = item;
for (int i = 1; i < data.length; ++i) {
next = next.addRepeatable(data[i]);
}
return item;
}
private QueueItem createNewItem(byte[] data, boolean repeatable) {
QueueItem newItem = new QueueItem(socket, uniqueCommandId, data, repeatable, delayTime, repeatCommands,
packet.getAddress(), packet.getPort(), root);
// The the next pointer of the last element in the chain to the new item
QueueItem lastInChain = root.last != null ? root.last : root;
lastInChain.next = newItem;
return newItem;
}
/**
* Add a command to the command chain. Can be called on any of the
* commands in the chain, but will overwrite the next command in chain.
*
* This method can be used in a cascading way like:
* QueueItem item ...;
* send(item.addNonRepeatable(...).addNonRepeatable(...))
*
*
* @param data Add data to the chain of commands
* @return Always return the root command
*/
public QueueItem addNonRepeatable(byte[] data) {
root.last = createNewItem(data, false);
return this.root;
}
/**
* Add a command to the command chain. Can be called on any of the
* commands in the chain, but will overwrite the next command in chain.
*
* This method can be used in a cascading way like:
* QueueItem item ...;
* send(item.addRepeatable(...).addRepeatable(...))
*
*
* @param data Add data to the chain of commands
* @return Always return the root command
*/
public QueueItem addRepeatable(byte[] data) {
root.last = createNewItem(data, true);
return this.root;
}
public boolean isInvalid() {
return invalid;
}
public void makeInvalid() {
this.invalid = true;
}
}

View File

@@ -0,0 +1,171 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.protocol;
import java.io.Closeable;
import java.io.IOException;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This implements a queue for UDP sending, where each item to be send is associated with an id.
* If a new item is added, that has the same id of an already queued item, it replaces the
* queued item. This is used for milight packets, where older bridges accept commands with a 100ms
* delay only. The user may issue absolute brightness or color changes faster than 1/10s though, and we don't
* want to just queue up those commands but apply the newest command only.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class QueuedSend implements Runnable, Closeable {
private final Logger logger = LoggerFactory.getLogger(QueuedSend.class);
final BlockingQueue<QueueItem> queue = new LinkedBlockingQueue<>(20);
private boolean willbeclosed = false;
private @Nullable Thread thread;
public static final byte NO_CATEGORY = 0;
/**
* Start the send thread of this queue. Call dispose() to quit the thread.
*/
public void start() {
willbeclosed = false;
thread = new Thread(this);
thread.start();
}
/**
* The queue process
*/
@Override
public void run() {
QueueItem item = null;
while (!willbeclosed) {
// If the command belongs to a chain of commands, get the next command now.
if (item != null && item.next != null) {
item = item.next;
} else {
try {
// block/wait for another item
item = queue.take();
} catch (InterruptedException e) {
if (!willbeclosed) {
logger.error("Queue take failed: {}", e.getLocalizedMessage());
}
break;
}
}
if (item.isInvalid()) {
// Just in case it is a command chain, set the item to null to not process any chained commands.
item = null;
continue;
}
try {
for (int i = 0; i < (item.repeatable ? item.repeatCommands : 1); ++i) {
item.socket.send(item.packet);
if (ProtocolConstants.DEBUG_SESSION) {
StringBuilder s = new StringBuilder();
for (int c = 0; c < item.packet.getData().length; ++c) {
s.append(String.format("%02X ", item.packet.getData()[c]));
}
logger.debug("Sent packet '{}' to bridge {}", s.toString(),
item.packet.getAddress().getHostAddress());
}
}
} catch (IOException e) {
logger.warn("Failed to send Message to '{}': {}", item.packet.getAddress().getHostAddress(),
e.getMessage());
}
try {
Thread.sleep(item.delayTime);
} catch (InterruptedException e) {
if (!willbeclosed) {
logger.warn("Queue sleep failed: {}", e.getLocalizedMessage());
}
break;
}
}
}
/**
* Mark all commands in the queue invalid that have the same unique id as the given one. This does not synchronise
* with the sender thread. If an element has been started to being processed, this method has no more effect on that
* element. Command chains are always executed in a row. Even if the head of the command queue has been marked
* as invalid, if the processing has been started, the chain will be processed completely.
*
* @param uniqueCommandId
*/
private void removeFromQueue(int uniqueCommandId) {
Iterator<QueueItem> iterator = queue.iterator();
while (iterator.hasNext()) {
try {
QueueItem item = iterator.next();
if (item.uniqueCommandId == uniqueCommandId) {
item.makeInvalid();
}
} catch (IllegalStateException e) {
// Ignore threading errors
} catch (NoSuchElementException e) {
// The element might have been processed already while iterate.
// Ignore NoSuchElementException here.
}
}
}
/**
* Add data to the send queue.
*
* <p>
* You have to create your own QueueItem. This allows to you create a chain of commands. A chain will always
* executed in order and without interrupting the sequence with another command. A chain will be removed completely
* if another command with the same category is added except if the chain has been started to be processed.
* </p>
*
* @param item A queue item, cannot be null.
*/
public void queue(QueueItem item) {
if (item.uniqueCommandId != NO_CATEGORY) {
removeFromQueue(item.uniqueCommandId);
}
queue.offer(item);
}
/**
* Once closed, this object can't be reused anymore.
*/
@Override
public void close() throws IOException {
willbeclosed = true;
final Thread threadL = this.thread;
if (threadL != null) {
try {
threadL.join(200);
} catch (InterruptedException e) {
}
threadL.interrupt();
}
this.thread = null;
}
}

View File

@@ -0,0 +1,344 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.test;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.util.Arrays;
import org.openhab.binding.milight.internal.MilightBindingConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Emulates a Milight V6 iBox bridge to test and intercept communication with the official apps,
* as well as test the binding to be conformant to the protocol.
*
* @author David Graeff - Initial contribution
*/
public class EmulatedV6Bridge {
protected final Logger logger = LoggerFactory.getLogger(EmulatedV6Bridge.class);
private boolean willbeclosed = false;
private static final byte SID1 = (byte) 0xed;
private static final byte SID2 = (byte) 0xab;
private static final byte PW1 = 0;
private static final byte PW2 = 0;
// These bytes are the client session bytes
private byte cls1 = (byte) 0xf6;
private byte cls2 = (byte) 0x0D;
private static final byte SEQ1 = 0, SEQ2 = 0;
private static final byte[] FAKE_MAC = { (byte) 0xAC, (byte) 0xCF, (byte) 0x23, (byte) 0xF5, (byte) 0x7A,
(byte) 0xD4 };
// Send to the network by clients to find V6 bridges
private byte searchBroadcast[] = new byte[] { 0x10, 0, 0, 0, 0x24, 0x02, cls1, cls2, 0x02, 0x39, 0x38, 0x35, 0x62,
0x31, 0x35, 0x37, 0x62, 0x66, 0x36, 0x66, 0x63, 0x34, 0x33, 0x33, 0x36, 0x38, 0x61, 0x36, 0x33, 0x34, 0x36,
0x37, 0x65, 0x61, 0x33, 0x62, 0x31, 0x39, 0x64, 0x30, 0x64 };
// Send to broadcast address by the client usually and used to test if the client with the contained bridge id
// is present on the network. If the IP of the bridge is known already, then SESSION_REQUEST is used usually.
private byte sessionRequestFindBroadcast[] = new byte[] { 0x10, 0, 0, 0, 0x0A, 2, cls1, cls2, 1, FAKE_MAC[0],
FAKE_MAC[1], FAKE_MAC[2], FAKE_MAC[3], FAKE_MAC[4], FAKE_MAC[5] };
// Some clients send this as first command to get a session id, especially if the bridge IP is already known.
private byte sessionRequest[] = new byte[] { (byte) 0x20, 0, 0, 0, (byte) 0x16, 2, (byte) 0x62, (byte) 0x3A,
(byte) 0xD5, (byte) 0xED, (byte) 0xA3, 1, (byte) 0xAE, (byte) 0x08, (byte) 0x2D, (byte) 0x46, (byte) 0x61,
(byte) 0x41, (byte) 0xA7, (byte) 0xF6, (byte) 0xDC, (byte) 0xAF, cls1, cls2, 0, 0, (byte) 0x1E };
private byte sessionResponse[] = { (byte) 0x28, 0, 0, 0, (byte) 0x11, 0, 2, (byte) 0xAC, (byte) 0xCF, (byte) 0x23,
(byte) 0xF5, (byte) 0x7A, (byte) 0xD4, (byte) 0x69, (byte) 0xF0, (byte) 0x3C, (byte) 0x23, 0, 1, SID1, SID2,
0 };
// Some clients call this as second command to establish a session.
private static final byte ESTABLISH_SESSION_REQUEST[] = new byte[] { (byte) 0x30, 0, 0, 0, 3, SID1, SID2, 0 };
// In response to SEARCH, ESTABLISH_SESSION_REQUEST but also to SESSION_REQUEST_FIND_BROADCAST
private static final byte REESTABLISH_SESSION_RESPONSE[] = new byte[] { (byte) 0x18, 0, 0, 0, (byte) 0x40, 2,
FAKE_MAC[0], FAKE_MAC[1], FAKE_MAC[2], FAKE_MAC[3], FAKE_MAC[4], FAKE_MAC[5], 0, (byte) 0x20, (byte) 0x39,
(byte) 0x38, (byte) 0x35, (byte) 0x62, (byte) 0x31, (byte) 0x35, (byte) 0x37, (byte) 0x62, (byte) 0x66,
(byte) 0x36, (byte) 0x66, (byte) 0x63, (byte) 0x34, (byte) 0x33, (byte) 0x33, (byte) 0x36, (byte) 0x38,
(byte) 0x61, (byte) 0x36, (byte) 0x33, (byte) 0x34, (byte) 0x36, (byte) 0x37, (byte) 0x65, (byte) 0x61,
(byte) 0x33, (byte) 0x62, (byte) 0x31, (byte) 0x39, (byte) 0x64, (byte) 0x30, (byte) 0x64, 1, 0, 1,
(byte) 0x17, (byte) 0x63, 0, 0, (byte) 0x05, 0, (byte) 0x09, (byte) 0x78, (byte) 0x6C, (byte) 0x69,
(byte) 0x6E, (byte) 0x6B, (byte) 0x5F, (byte) 0x64, (byte) 0x65, (byte) 0x76, (byte) 0x07, (byte) 0x5B,
(byte) 0xCD, (byte) 0x15 };
private static final byte REGISTRATION_REQUEST[] = { (byte) 0x80, 0, 0, 0, 0x11, SID1, SID2, SEQ1, SEQ2, 0, 0x33,
PW1, PW2, 0, 0, 0, 0, 0, 0, 0, 0, 0x33 };
// 80:00:00:00:15:(f0:fe:6b:16:b0:8a):05:02:00:34:00:00:00:00:00:00:00:00:00:00:34
private static final byte REGISTRATION_REQUEST_RESPONSE[] = { (byte) 0x80, 0, 0, 0, 0x15, FAKE_MAC[0], FAKE_MAC[1],
FAKE_MAC[2], FAKE_MAC[3], FAKE_MAC[4], FAKE_MAC[5], 5, 2, 0, 0x34, PW1, PW2, 0, 0, 0, 0, 0, 0, 0, 0, 0x34 };
private static final byte[] KEEP_ALIVE_REQUEST = { (byte) 0xD0, 0, 0, 0, 2, SID1, SID2 };
private static final byte[] KEEP_ALIVE_RESPONSE = { (byte) 0xD8, 0, 0, 0, (byte) 0x07, FAKE_MAC[0], FAKE_MAC[1],
FAKE_MAC[2], FAKE_MAC[3], FAKE_MAC[4], FAKE_MAC[5], 1 };
EmulatedV6Bridge() {
new Thread(this::runDiscovery).start();
new Thread(this::runBrigde).start();
}
private void replaceWithMac(byte data[], int offset) {
data[offset + 0] = FAKE_MAC[0];
data[offset + 1] = FAKE_MAC[1];
data[offset + 2] = FAKE_MAC[2];
data[offset + 3] = FAKE_MAC[3];
data[offset + 4] = FAKE_MAC[4];
data[offset + 5] = FAKE_MAC[5];
}
public void runDiscovery() {
final byte discover[] = "HF-A11ASSISTHREAD".getBytes();
try {
byte[] a = new byte[0];
DatagramPacket sPacket = new DatagramPacket(a, a.length);
DatagramSocket datagramSocket = new DatagramSocket(MilightBindingConstants.PORT_DISCOVER);
debugSession("EmulatedV6Bridge discover thread ready");
byte[] buffer = new byte[1024];
DatagramPacket rPacket = new DatagramPacket(buffer, buffer.length);
// Now loop forever, waiting to receive packets and printing them.
while (!willbeclosed) {
rPacket.setLength(buffer.length);
datagramSocket.receive(rPacket);
sPacket.setAddress(rPacket.getAddress());
sPacket.setPort(rPacket.getPort());
int len = rPacket.getLength();
if (len >= discover.length) {
if (Arrays.equals(discover, Arrays.copyOf(buffer, discover.length))) {
String data = rPacket.getAddress().getHostAddress() + ",ACCF23F57AD4,HF-LPB100";
debugSession("Discover message received. Send: " + data);
sendMessage(sPacket, datagramSocket, data.getBytes());
continue;
}
}
// logUnknownPacket(buffer, len, "No valid discovery received");
}
} catch (IOException e) {
if (willbeclosed) {
return;
}
logger.error("{}", e.getLocalizedMessage());
}
}
public void runBrigde() {
try {
byte[] a = new byte[0];
DatagramPacket sPacket = new DatagramPacket(a, a.length);
DatagramSocket datagramSocket = new DatagramSocket(MilightBindingConstants.PORT_VER6);
debugSession("EmulatedV6Bridge control thread ready");
byte[] buffer = new byte[1024];
DatagramPacket rPacket = new DatagramPacket(buffer, buffer.length);
// Now loop forever, waiting to receive packets and printing them.
while (!willbeclosed) {
rPacket.setLength(buffer.length);
datagramSocket.receive(rPacket);
sPacket.setAddress(rPacket.getAddress());
sPacket.setPort(rPacket.getPort());
int len = rPacket.getLength();
if (len < 5 || buffer[1] != 0 || buffer[2] != 0 || buffer[3] != 0) {
logUnknownPacket(buffer, len, "Not an iBox request!");
continue;
}
if (len >= sessionRequest.length && buffer[0] == sessionRequest[0]) {
sessionRequest[22] = buffer[22];
sessionRequest[23] = buffer[23];
boolean eq = ByteBuffer.wrap(sessionRequest, 0, sessionRequest.length)
.equals(ByteBuffer.wrap(buffer, 0, sessionRequest.length));
if (eq) {
debugSession("session get message received");
sessionResponse[19] = SID1;
sessionResponse[20] = SID2;
replaceWithMac(sessionResponse, 7);
debugSessionSend(sessionResponse, sPacket.getAddress());
sendMessage(sPacket, datagramSocket, sessionResponse);
continue;
}
}
if (len >= sessionRequestFindBroadcast.length && buffer[0] == sessionRequestFindBroadcast[0]) {
sessionRequestFindBroadcast[6] = buffer[6];
sessionRequestFindBroadcast[7] = buffer[7];
boolean eq = ByteBuffer.wrap(sessionRequestFindBroadcast, 0, 6)
.equals(ByteBuffer.wrap(buffer, 0, 6));
if (eq) {
debugSession("init session received");
cls1 = sessionRequestFindBroadcast[6];
cls2 = sessionRequestFindBroadcast[7];
replaceWithMac(REESTABLISH_SESSION_RESPONSE, 6);
debugSessionSend(REESTABLISH_SESSION_RESPONSE, sPacket.getAddress());
sendMessage(sPacket, datagramSocket, REESTABLISH_SESSION_RESPONSE);
continue;
}
}
if (len >= searchBroadcast.length && buffer[0] == searchBroadcast[0]) {
searchBroadcast[6] = buffer[6];
searchBroadcast[7] = buffer[7];
boolean eq = ByteBuffer.wrap(searchBroadcast, 0, searchBroadcast.length)
.equals(ByteBuffer.wrap(buffer, 0, searchBroadcast.length));
if (eq) {
debugSession("Search request");
cls1 = searchBroadcast[6];
cls2 = searchBroadcast[7];
replaceWithMac(REESTABLISH_SESSION_RESPONSE, 6);
debugSessionSend(REESTABLISH_SESSION_RESPONSE, sPacket.getAddress());
sendMessage(sPacket, datagramSocket, REESTABLISH_SESSION_RESPONSE);
continue;
}
}
if (len >= ESTABLISH_SESSION_REQUEST.length && buffer[0] == ESTABLISH_SESSION_REQUEST[0]) {
ESTABLISH_SESSION_REQUEST[5] = SID1;
ESTABLISH_SESSION_REQUEST[6] = SID2;
boolean eq = ByteBuffer.wrap(ESTABLISH_SESSION_REQUEST, 0, ESTABLISH_SESSION_REQUEST.length)
.equals(ByteBuffer.wrap(buffer, 0, ESTABLISH_SESSION_REQUEST.length));
if (eq) {
debugSession("Session establish request");
replaceWithMac(REESTABLISH_SESSION_RESPONSE, 6);
debugSessionSend(REESTABLISH_SESSION_RESPONSE, sPacket.getAddress());
sendMessage(sPacket, datagramSocket, REESTABLISH_SESSION_RESPONSE);
continue;
}
}
if (len >= KEEP_ALIVE_REQUEST.length && buffer[0] == KEEP_ALIVE_REQUEST[0]) {
KEEP_ALIVE_REQUEST[5] = SID1;
KEEP_ALIVE_REQUEST[6] = SID2;
boolean eq = ByteBuffer.wrap(KEEP_ALIVE_REQUEST, 0, KEEP_ALIVE_REQUEST.length)
.equals(ByteBuffer.wrap(buffer, 0, KEEP_ALIVE_REQUEST.length));
if (eq) {
debugSession("keep alive received");
replaceWithMac(KEEP_ALIVE_RESPONSE, 5);
debugSessionSend(KEEP_ALIVE_RESPONSE, sPacket.getAddress());
sendMessage(sPacket, datagramSocket, KEEP_ALIVE_RESPONSE);
continue;
}
}
if (len >= REGISTRATION_REQUEST.length && buffer[0] == REGISTRATION_REQUEST[0]) {
byte seq = buffer[8];
if (buffer[5] != SID1 || buffer[6] != SID2) {
logUnknownPacket(buffer, len,
"No valid ssid. Current ssid is " + String.format("%02X %02X", SID1, SID2));
continue;
}
if (buffer[11] != PW1 || buffer[12] != PW2) {
logUnknownPacket(buffer, len,
"No valid password. Current password is " + String.format("%02X %02X", PW1, PW2));
continue;
}
if (buffer[4] == 0x11) {
if (buffer[10] == 0x33) {
replaceWithMac(REGISTRATION_REQUEST_RESPONSE, 5);
sendMessage(sPacket, datagramSocket, REGISTRATION_REQUEST_RESPONSE);
} else if (buffer[10] == 0x31) {
// 80 00 00 00 11 {WifiBridgeSessionID1} {WifiBridgeSessionID2} 00 {SequenceNumber} 00 0x31
// {PasswordByte1 default 00} {PasswordByte2 default 00} {remoteStyle 08 for RGBW/WW/CW or
// 00 for bridge lamp} {LightCommandByte1} {LightCommandByte2} 0 0 0 {Zone1-4 0=All} 0
// {Checksum}
byte chksum = (byte) (buffer[10 + 0] + buffer[10 + 1] + buffer[10 + 2] + buffer[10 + 3]
+ buffer[10 + 4] + buffer[10 + 5] + buffer[10 + 6] + buffer[10 + 7] + buffer[10 + 8]
+ buffer[19]);
if (chksum != buffer[21]) {
logger.error("Checksum wrong:{} {}", chksum, buffer[21]);
continue;
}
StringBuilder debugStr = new StringBuilder();
if (buffer[13] == 0x08) {
debugStr.append("RGBWW ");
} else if (buffer[13] == 0x07) {
debugStr.append("RGBW ");
} else {
debugStr.append("iBox ");
}
debugStr.append("Zone " + String.valueOf(buffer[19]) + " ");
for (int i = 13; i < 19; ++i) {
debugStr.append(String.format("%02X ", buffer[i]));
}
logger.debug("{}", debugStr);
}
}
byte response[] = { (byte) 0x88, 0, 0, 0, (byte) 0x03, 0, seq, 0 };
sendMessage(sPacket, datagramSocket, response);
continue;
}
logUnknownPacket(buffer, len, "Not recognised command");
}
} catch (
IOException e) {
if (willbeclosed) {
return;
}
logger.error("{}", e.getLocalizedMessage());
}
}
protected void logUnknownPacket(byte[] data, int len, String reason) {
StringBuilder s = new StringBuilder();
for (int i = 0; i < len; ++i) {
s.append(String.format("%02X ", data[i]));
}
logger.error("{}: {}", reason, s);
}
protected void sendMessage(DatagramPacket packet, DatagramSocket datagramSocket, byte buffer[]) {
packet.setData(buffer);
try {
datagramSocket.send(packet);
} catch (IOException e) {
logger.error("Failed to send Message to '{}', Error message: {}",
new Object[] { packet.getAddress().getHostAddress(), e.getMessage() });
}
}
private void debugSessionSend(byte buffer[], InetAddress address) {
StringBuilder s = new StringBuilder();
for (int i = 0; i < buffer.length; ++i) {
s.append(String.format("%02X ", buffer[i]));
}
// logger.debug("Sent packet '{}' to ({})", new Object[] { s.toString(), address.getHostAddress() });
}
private void debugSession(String msg) {
// logger.debug(msg);
}
}

View File

@@ -0,0 +1,47 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.milight.internal.test;
import org.openhab.binding.milight.internal.MilightBindingConstants;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryService;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A simple discovery service that will make the emulated V6 bridge visible to the Inbox of OH.
* Enable this in OSGI-INF/TestDiscovery.xml with enabled="true".
*
* @author David Graeff - Initial contribution
*/
@Component(service = DiscoveryService.class, immediate = true, enabled = false)
public class TestDiscovery extends AbstractDiscoveryService {
private final Logger logger = LoggerFactory.getLogger(TestDiscovery.class);
@SuppressWarnings("unused")
private EmulatedV6Bridge server;
public TestDiscovery() {
super(MilightBindingConstants.BRIDGE_THING_TYPES_UIDS, 2, true);
try {
server = new EmulatedV6Bridge();
} catch (Exception e) {
logger.warn("An error occurred", e);
}
}
@Override
protected void startScan() {
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="milight" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>Milight Binding</name>
<description>A binding for Milight/Easybulb/compatible white, color and color+white bulbs.</description>
<author>David Gräff</author>
</binding:binding>

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="milight"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-type id="lednightmode">
<item-type>Switch</item-type>
<label>Nightmode</label>
<description>Switch to night mode, a very dimmed brightness mode</description>
<state>
<options>
<option value="ON">Nightmode</option>
</options>
</state>
</channel-type>
<channel-type id="ledwhitemode">
<item-type>Switch</item-type>
<label>Whitemode</label>
<description>Switch to white mode, which basically sets the saturation to 0 (turns off the color leds)</description>
<state>
<options>
<option value="ON">Whitemode</option>
</options>
</state>
</channel-type>
<channel-type id="ledlink" advanced="true">
<item-type>Switch</item-type>
<label>Link Bulb</label>
<description>Sync bulb to this zone within 3 seconds of light bulb socket power on</description>
</channel-type>
<channel-type id="ledunlink" advanced="true">
<item-type>Switch</item-type>
<label>Unlink Bulb</label>
<description>Clear bulb from this zone within 3 seconds of light bulb socket power on</description>
</channel-type>
<channel-type id="ledcolor">
<item-type>Color</item-type>
<label>Color</label>
<description>Color of the LED. Bind to a Dimmer to just set the brightness, bind to a Color chooser for the full
control and bind to a Switch for turning the led on or off.
</description>
<category>ColorLight</category>
<tags>
<tag>ColorLighting</tag>
<tag>Lighting</tag>
</tags>
</channel-type>
<channel-type id="ledbrightness">
<item-type>Dimmer</item-type>
<label>Brightness</label>
<description>The brightness can be set in 16 steps for RGBW/White leds and in 64 steps for RGBWW leds</description>
<category>Light</category>
<tags>
<tag>Lighting</tag>
</tags>
<state min="0" max="100" step="1" pattern="%d"></state>
</channel-type>
<channel-type id="ledsaturation" advanced="true">
<item-type>Dimmer</item-type>
<label>Saturation</label>
<description>The saturation can be set in 64 steps for RGBWW leds</description>
<state min="0" max="100" step="1" pattern="%d"></state>
</channel-type>
<channel-type id="ledtemperature">
<item-type>Dimmer</item-type>
<label>Color Temperature</label>
<description>White leds and RGBWW allow to change between a cold and a warm color temperature. White support 16, RGBWW
support 64 steps
</description>
<category>DimmableLight</category>
<state min="0" max="100" step="1" pattern="%d"></state>
</channel-type>
<channel-type id="animation_speed_relative">
<item-type>Dimmer</item-type>
<label>Animation Speed</label>
<description>The speed of some animations can be increased or decreased</description>
</channel-type>
<channel-type id="animation_mode_relative">
<item-type>Dimmer</item-type>
<label>Animation Mode</label>
<description>Switch to the next/previous animation mode of your RGBW or white LED. Bind this to a Next/Previous
channel type.
</description>
</channel-type>
<channel-type id="animation_mode">
<item-type>Number</item-type>
<label>Animation Mode</label>
<description>Animation mode of your LED. RGBWW leds support 9 animation modes.</description>
<category>Light</category>
<state>
<options>
<option value="1">Animation 1</option>
<option value="2">Animation 2</option>
<option value="3">Animation 3</option>
<option value="4">Animation 4</option>
<option value="5">Animation 5</option>
<option value="6">Animation 6</option>
<option value="7">Animation 7</option>
<option value="8">Animation 8</option>
<option value="9">Animation 9</option>
</options>
</state>
</channel-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="milight"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="rgbv2Led">
<supported-bridge-type-refs>
<bridge-type-ref id="bridgeV3"/>
</supported-bridge-type-refs>
<label>Color Bulb (old)</label>
<description>The oldest produced color bulb. Without a white channel. No saturation support.</description>
<category>Lightbulb</category>
<channels>
<channel id="ledbrightness" typeId="ledbrightness"/>
<channel id="ledcolor" typeId="ledcolor"/>
<channel id="animation_mode_relative" typeId="animation_mode_relative"/>
</channels>
<config-description>
<parameter name="zone" type="integer" required="true">
<label>Zone</label>
<description>A milight bulb can be assigned to zone 0-4. zone 0 controls all bulbs of that type.
</description>
<default>1</default>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="milight"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<bridge-type id="bridgeV3">
<label>Milight Bridge (first Edition)</label>
<description>A Milight/Easybulb bridge. This bridge cannot handle newer light bulbs (2016+) of the aforementioned
manufacturers.</description>
<config-description>
<parameter name="host" type="text">
<label>IP or Host Name</label>
<description>You either need an IP/Hostname or the Bridge ID.</description>
<context>network-address</context>
</parameter>
<parameter name="repeat" type="integer" required="false" min="0" max="5">
<label>Repeat Commands</label>
<description>Usually the bridge receives all commands albeit UDP is used. But the actual bulbs might be slightly out
of bridge radio range and it sometimes helps to send commands multiple times.
</description>
<default>1</default>
<advanced>true</advanced>
</parameter>
<parameter name="delayTime" type="integer" required="false" min="0" max="400">
<label>Wait Between Commands (ms)</label>
<description>Time to wait before sending another command to the bridge. It is safe to have a wait time of 1/10s but
usually sufficient to just wait 50ms. If the value is too high, commands queue up.
</description>
<default>100</default>
<advanced>true</advanced>
</parameter>
<parameter name="port" type="integer" required="false">
<label>Custom Port</label>
<description>You can set a custom port that will take precedence over the default port which is selected depending
on the bridge version: Version 6 uses 5987, Version 3/4/5 uses 8899. Version 2 uses 50000.
</description>
<advanced>true</advanced>
</parameter>
<parameter name="bridgeid" type="text">
<label>Bridge ID</label>
<description>The mac address of the bridge in upper case letters without delimiter.
Use this parameter and leave the
IP/Hostname empty for DHCP environments where IPs may often change over time.
The Bridge ID is also used to check if
a given IP corresponds to the right device.
The bridge is set offline if the device does not respond with the
correct Bridge ID and a re-detection is started.
</description>
<advanced>true</advanced>
</parameter>
<parameter name="refreshTime" type="integer" min="5" max="300">
<label>Refresh Interval</label>
<description>Interval in seconds to check for device presence. The Bridge ID is used to check if the IP is still the
right one.
</description>
<default>10</default>
<advanced>true</advanced>
</parameter>
</config-description>
</bridge-type>
<thing-type id="rgbLed">
<supported-bridge-type-refs>
<bridge-type-ref id="bridgeV3"/>
</supported-bridge-type-refs>
<label>Color Bulb (first Edition)</label>
<description>RGB bulb with white channel. No saturation control. If the set saturation is below a threshold of 50%,
the bulb turns into white mode.</description>
<category>Lightbulb</category>
<channels>
<channel id="lednightmode" typeId="lednightmode"/>
<channel id="ledwhitemode" typeId="ledwhitemode"/>
<channel id="ledbrightness" typeId="ledbrightness"/>
<channel id="ledcolor" typeId="ledcolor"/>
<channel id="animation_speed_relative" typeId="animation_speed_relative"/>
<channel id="animation_mode_relative" typeId="animation_mode_relative"/>
</channels>
<config-description>
<parameter name="zone" type="integer" required="true">
<label>Zone</label>
<description>A milight bulb can be assigned to zone 0-4. zone 0 controls all bulbs of that type.
</description>
<default>1</default>
</parameter>
</config-description>
</thing-type>
<thing-type id="whiteLed">
<supported-bridge-type-refs>
<bridge-type-ref id="bridgeV3"/>
</supported-bridge-type-refs>
<label>Cold/warm White Bulb (first Edition)</label>
<description>White bulb for the older bridge (up to 2016)</description>
<category>Lightbulb</category>
<channels>
<channel id="lednightmode" typeId="lednightmode"/>
<channel id="ledbrightness" typeId="ledbrightness"/>
<channel id="ledtemperature" typeId="ledtemperature"/>
<channel id="animation_mode_relative" typeId="animation_mode_relative"/>
</channels>
<config-description>
<parameter name="zone" type="integer" required="true">
<label>Zone</label>
<description>A milight bulb can be assigned to zone 0-4. zone 0 controls all bulbs of that type.
</description>
<default>1</default>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,173 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="milight"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<bridge-type id="bridgeV6">
<label>Milight Bridge (iBox)</label>
<description>A bridge for all newer light bulbs (2016+) of the Milight/Easybulb system.</description>
<properties>
<property name="sessionid">N/A</property>
<property name="sessionid_last_refresh">N/A</property>
</properties>
<config-description>
<parameter name="host" type="text" required="true">
<label>IP or Host Name</label>
<description>Will be resolved by discovery if auto configured
</description>
<context>network-address</context>
</parameter>
<parameter name="passwordByte1" type="integer" required="true" min="0" max="255">
<label>Password Byte 1</label>
<description>Bridge V6 allows to set two password bytes. A value from 0-255 is allowed.
</description>
<context>password</context>
<default>0</default>
</parameter>
<parameter name="passwordByte2" type="integer" required="true" min="0" max="255">
<label>Password Byte 2</label>
<description>Bridge V6 allows to set two password bytes. A value from 0-255 is allowed.
</description>
<context>password</context>
<default>0</default>
</parameter>
<parameter name="repeat" type="integer" required="false" min="0" max="5">
<label>Repeat Commands</label>
<description>Usually the bridge receives all commands albeit UDP is used. But the actual bulbs might be slightly out
of bridge radio range and it sometimes helps to send commands multiple times.
</description>
<default>1</default>
<advanced>true</advanced>
</parameter>
<parameter name="delayTime" type="integer" required="false" min="0" max="400">
<label>Wait Between Commands (ms)</label>
<description>Time to wait before sending another command to the bridge. It is safe to have a wait time of 1/10s but
usually sufficient to just wait 50ms. If the value is too high, commands queue up.
</description>
<default>100</default>
<advanced>true</advanced>
</parameter>
<parameter name="port" type="integer" required="false">
<label>Custom Port</label>
<description>You can set a custom port that will take precedence over the default port which is selected depending
on the bridge version: Version 6 uses 5987, Version 3/4/5 uses 8899. Version 2 uses 50000.
</description>
<advanced>true</advanced>
</parameter>
<parameter name="bridgeid" type="text" required="true">
<label>Bridge ID</label>
<description>The mac address of the bridge in upper case letters without delimiter.
This is used to check if the
given IP corresponds to the right device. The bridge is set offline if the device
does not respond with the correct
Bride ID and a re-detection is started. Useful for DHCP environments where
IPs may change over time, after power
outage etc. Will be resolved by discovery if auto configured.
</description>
<advanced>true</advanced>
</parameter>
<parameter name="refreshTime" type="integer" min="100" max="10000" required="true">
<label>Keep Alive Interval</label>
<description>Interval in milliseconds to send a keep alive ping. If the value is too high, a session may expire and
the bridge and all devices could go offline for a few seconds.
</description>
<default>5000</default>
<advanced>true</advanced>
</parameter>
</config-description>
</bridge-type>
<thing-type id="rgbiboxLed">
<supported-bridge-type-refs>
<bridge-type-ref id="bridgeV6"/>
</supported-bridge-type-refs>
<label>Integrated Bulb (iBox)</label>
<description>Integrated bulb of the ibox bridge with no dedicated white channel and therefore no saturation control
</description>
<category>Lightbulb</category>
<channels>
<channel id="ledwhitemode" typeId="ledwhitemode"/>
<channel id="ledbrightness" typeId="ledbrightness"/>
<channel id="ledcolor" typeId="ledcolor"/>
<channel id="animation_speed_relative" typeId="animation_speed_relative"/>
<channel id="animation_mode" typeId="animation_mode"/>
<channel id="animation_mode_relative" typeId="animation_mode_relative"/>
</channels>
<config-description>
<parameter name="zone" type="integer" required="true">
<label>Zone</label>
<description>A milight bulb can be assigned to zone 0-4. zone 0 controls all bulbs of that type.
</description>
<default>1</default>
</parameter>
</config-description>
</thing-type>
<thing-type id="rgbwLed">
<supported-bridge-type-refs>
<bridge-type-ref id="bridgeV6"/>
</supported-bridge-type-refs>
<label>Color Bulb with Cold White (iBox)</label>
<description>Color bulb with white channel for the new Milight/Easybulb system.</description>
<category>Lightbulb</category>
<channels>
<channel id="ledlink" typeId="ledlink"/>
<channel id="ledunlink" typeId="ledunlink"/>
<channel id="lednightmode" typeId="lednightmode"/>
<channel id="ledwhitemode" typeId="ledwhitemode"/>
<channel id="ledbrightness" typeId="ledbrightness"/>
<channel id="ledcolor" typeId="ledcolor"/>
<channel id="animation_speed_relative" typeId="animation_speed_relative"/>
<channel id="animation_mode" typeId="animation_mode"/>
<channel id="animation_mode_relative" typeId="animation_mode_relative"/>
</channels>
<config-description>
<parameter name="zone" type="integer" required="true">
<label>Zone</label>
<description>A milight bulb can be assigned to zone 0-4. zone 0 controls all bulbs of that type.
</description>
<default>1</default>
</parameter>
</config-description>
</thing-type>
<thing-type id="rgbwwLed">
<supported-bridge-type-refs>
<bridge-type-ref id="bridgeV6"/>
</supported-bridge-type-refs>
<label>Color Bulb with Cold/warm White (iBox)</label>
<description>Color bulb with warm and cold white support for the new Milight/Easybulb system.</description>
<category>Lightbulb</category>
<channels>
<channel id="ledlink" typeId="ledlink"/>
<channel id="ledunlink" typeId="ledunlink"/>
<channel id="lednightmode" typeId="lednightmode"/>
<channel id="ledwhitemode" typeId="ledwhitemode"/>
<channel id="ledtemperature" typeId="ledtemperature"/>
<channel id="ledbrightness" typeId="ledbrightness"/>
<channel id="ledsaturation" typeId="ledsaturation"/>
<channel id="ledcolor" typeId="ledcolor"/>
<channel id="animation_speed_relative" typeId="animation_speed_relative"/>
<channel id="animation_mode" typeId="animation_mode"/>
<channel id="animation_mode_relative" typeId="animation_mode_relative"/>
</channels>
<config-description>
<parameter name="zone" type="integer" required="true">
<label>Zone</label>
<description>A milight bulb can be assigned to zone 0-4. zone 0 controls all bulbs of that type.
</description>
<default>1</default>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>