From 5d2f7997f96966755af27fce1ec49d3e43c9bd27 Mon Sep 17 00:00:00 2001 From: leluna Date: Thu, 15 Aug 2019 17:19:38 +0200 Subject: [PATCH 1/4] Add hue gateway as thing to activate predefined scenes Signed-off-by: leluna --- bundles/org.openhab.binding.hue/README.md | 19 +++- .../hue/internal/HueBindingConstants.java | 3 +- .../binding/hue/internal/HueBridge.java | 11 +++ .../binding/hue/internal/HueObject.java | 2 +- .../hue/internal/HueThingHandlerFactory.java | 1 + .../binding/hue/internal/StateUpdate.java | 11 +++ .../discovery/HueLightDiscoveryService.java | 2 +- .../internal/handler/HueBridgeHandler.java | 94 +++++++++---------- .../hue/internal/handler/HueClient.java | 11 ++- .../main/resources/ESH-INF/thing/bridge.xml | 4 + .../main/resources/ESH-INF/thing/channels.xml | 8 +- 11 files changed, 112 insertions(+), 54 deletions(-) diff --git a/bundles/org.openhab.binding.hue/README.md b/bundles/org.openhab.binding.hue/README.md index 93aeb290ff9a8..a349aa83ba4a5 100644 --- a/bundles/org.openhab.binding.hue/README.md +++ b/bundles/org.openhab.binding.hue/README.md @@ -183,7 +183,7 @@ The devices support some of the following channels: | status | Number | This channel save status state for a CLIP sensor. | 0840 | | last_updated | DateTime | This channel the date and time when the sensor was last updated. | 0820, 0830, 0840, 0850, 0106, 0107, 0302| | battery_level | Number | This channel shows the battery level. | 0820, 0106, 0107, 0302 | -| battery_low | Switch | This channel indicates whether the battery is low or not. | 0820, 0106, 0107, 0302 | +| battery_low | Switch | This channel indicates whether the battery is low or not. | 0820, 0106, 0107, 0302 | ### Trigger Channels @@ -224,6 +224,17 @@ The `tap_switch_event` can trigger one of the following events: | Button 3 | Button 3 | 17 | | Button 4 | Button 4 | 18 | +### Scene Channel + +The bridge itself supports a channel to activate scenes: + +| Channel Type ID | Item Type | Description | +| --------------- | --------- | ----------- | +| scene | String | This channel activates the scene with the given ID String. | + +The scenes are identified by an ID String that is assigned by the Hue bridge. These must be aquired directly from [Hue REST API](https://developers.meethue.com/develop/hue-api/). + +This channel can then be used in the sitemap, for example as a switch or selection. ## Rule Actions @@ -301,6 +312,9 @@ Switch MotionSensorLowBattery { channel="hue:0107:1:motion-sensor:battery_lo // Temperature Sensor Number:Temperature TemperatureSensorTemperature { channel="hue:0302:temperature-sensor:temperature" } + +// Scenes +String LightScene { channel="hue:bridge:1:scene"} ``` Note: The bridge ID is in this example **1** but can be different in each system. @@ -336,6 +350,9 @@ sitemap demo label="Main Menu" Text item=MotionSensorLastUpdate Text item=MotionSensorBatteryLevel Switch item=MotionSensorLowBattery + + // Light Scenes + Switch item=LightScene label="Scene []" mappings=[abcdefgh1234567="Relax", ABCDEFGH1234567="Concentrate"] } } ``` diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java index 66eb7a84e89b0..40f2b31dee59e 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java @@ -75,6 +75,7 @@ public class HueBindingConstants { public static final String CHANNEL_DAYLIGHT = "daylight"; public static final String CHANNEL_STATUS = "status"; public static final String CHANNEL_FLAG = "flag"; + public static final String CHANNEL_SCENE = "scene"; // List all triggers public static final String EVENT_DIMMER_SWITCH = "dimmer_switch_event"; @@ -86,7 +87,7 @@ public class HueBindingConstants { public static final String PROTOCOL = "protocol"; public static final String USER_NAME = "userName"; - // Light config properties + // Configuration properties public static final String LIGHT_ID = "lightId"; public static final String SENSOR_ID = "sensorId"; public static final String PRODUCT_NAME = "productName"; diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java index 37f6ccac3627d..99b7db7d115a1 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java @@ -851,6 +851,17 @@ public void deleteSchedule(Schedule schedule) throws IOException, ApiException { handleErrors(result); } + /** + * Activate scene to all lights that belong to the scene. + * + * @param id the scene to be activated + * @throws IOException if the bridge cannot be reached + */ + public CompletableFuture recallScene(String id) { + Group allLightsGroup = new Group(); + return setGroupState(allLightsGroup, new StateUpdate().setScene(id)); + } + /** * Authenticate on the bridge as the specified user. * This function verifies that the specified username is valid and will use diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueObject.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueObject.java index 7ae7aa494fc25..c6f1b54a74e32 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueObject.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueObject.java @@ -18,7 +18,7 @@ import com.google.gson.reflect.TypeToken; /** - * Basic light information. + * Basic hue object information. * * @author Q42 - Initial contribution * @author Denis Dudnik - moved Jue library source code inside the smarthome Hue binding diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java index 2c0bf3df8a545..2073cf59d57fc 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java @@ -59,6 +59,7 @@ @NonNullByDefault @Component(service = ThingHandlerFactory.class, configurationPid = "binding.hue") public class HueThingHandlerFactory extends BaseThingHandlerFactory { + public static final Set SUPPORTED_THING_TYPES = Collections.unmodifiableSet( Stream.of(HueBridgeHandler.SUPPORTED_THING_TYPES.stream(), HueLightHandler.SUPPORTED_THING_TYPES.stream(), DimmerSwitchHandler.SUPPORTED_THING_TYPES.stream(), TapSwitchHandler.SUPPORTED_THING_TYPES.stream(), diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/StateUpdate.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/StateUpdate.java index 0d92d5e7f15f7..e1a65eb1c8907 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/StateUpdate.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/StateUpdate.java @@ -218,4 +218,15 @@ public StateUpdate setStatus(int status) { commands.add(new Command("status", status)); return this; } + + /** + * Recall the given scene. + * + * @param sceneId Identifier of the scene + * @return this object for chaining calls + */ + public StateUpdate setScene(String sceneId) { + commands.add(new Command("scene", sceneId)); + return this; + } } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueLightDiscoveryService.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueLightDiscoveryService.java index cac98bd813176..161343b9ec928 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueLightDiscoveryService.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueLightDiscoveryService.java @@ -53,7 +53,7 @@ import org.slf4j.LoggerFactory; /** - * The {@link HueBridgeServiceTracker} tracks for hue lights which are connected + * The {@link HueBridgeServiceTracker} tracks for hue lights, sensors and groups which are connected * to a paired hue bridge. The default search time for hue is 60 seconds. * * @author Kai Kreuzer - Initial contribution diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java index 156eb98a8996f..351aed05647a6 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java @@ -29,6 +29,7 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -36,6 +37,7 @@ import org.eclipse.smarthome.config.core.status.ConfigStatusMessage; import org.eclipse.smarthome.core.library.types.HSBType; import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.StringType; import org.eclipse.smarthome.core.thing.Bridge; import org.eclipse.smarthome.core.thing.ChannelUID; import org.eclipse.smarthome.core.thing.ThingStatus; @@ -326,7 +328,9 @@ public HueBridgeHandler(Bridge bridge) { @Override public void handleCommand(ChannelUID channelUID, Command command) { - // not needed + if (CHANNEL_SCENE.equals(channelUID.getId()) && command instanceof StringType) { + recallScene(command.toString()); + } } @Override @@ -354,10 +358,10 @@ public void updateSensorState(FullSensor sensor, StateUpdate stateUpdate) { try { hueBridge.handleErrors(result); } catch (Exception e) { - handleStateUpdateException(sensor, stateUpdate, e); + handleSensorUpdateException(sensor, e); } }).exceptionally(e -> { - handleStateUpdateException(sensor, stateUpdate, e); + handleSensorUpdateException(sensor, e); return null; }); } else { @@ -372,10 +376,10 @@ public void updateSensorConfig(FullSensor sensor, ConfigUpdate configUpdate) { try { hueBridge.handleErrors(result); } catch (Exception e) { - handleConfigUpdateException(sensor, configUpdate, e); + handleSensorUpdateException(sensor, e); } }).exceptionally(e -> { - handleConfigUpdateException(sensor, configUpdate, e); + handleSensorUpdateException(sensor, e); return null; }); } else { @@ -390,10 +394,10 @@ public void updateGroupState(FullGroup group, StateUpdate stateUpdate) { try { hueBridge.handleErrors(result); } catch (Exception e) { - handleStateUpdateException(group, stateUpdate, e); + handleGroupUpdateException(group, e); } }).exceptionally(e -> { - handleStateUpdateException(group, stateUpdate, e); + handleGroupUpdateException(group, e); return null; }); } else { @@ -411,58 +415,30 @@ private void handleStateUpdateException(FullLight light, StateUpdate stateUpdate updateLightState(light, LightStateConverter.toOnOffLightState(OnOffType.ON)); updateLightState(light, stateUpdate); } - } else if (e instanceof IOException) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - } else if (e instanceof EntityNotAvailableException) { - logger.debug("Error while accessing light: {}", e.getMessage(), e); - notifyLightStatusListeners(light, StatusType.GONE); - } else if (e instanceof ApiException) { - // This should not happen - if it does, it is most likely some bug that should be reported. - logger.warn("Error while accessing light: {}", e.getMessage(), e); - } else if (e instanceof IllegalStateException) { - logger.trace("Error while accessing light: {}", e.getMessage()); + } else { + handleThingUpdateException("light", e, gone -> notifyLightStatusListeners(light, gone)); } } - private void handleStateUpdateException(FullSensor sensor, StateUpdate stateUpdate, Throwable e) { - if (e instanceof IOException) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - } else if (e instanceof EntityNotAvailableException) { - logger.debug("Error while accessing sensor: {}", e.getMessage(), e); - notifySensorStatusListeners(sensor, StatusType.GONE); - } else if (e instanceof ApiException) { - // This should not happen - if it does, it is most likely some bug that should be reported. - logger.warn("Error while accessing sensor: {}", e.getMessage(), e); - } else if (e instanceof IllegalStateException) { - logger.trace("Error while accessing sensor: {}", e.getMessage()); - } + private void handleGroupUpdateException(FullGroup group, Throwable e) { + handleThingUpdateException("group", e, gone -> notifyGroupStatusListeners(group, gone)); } - private void handleStateUpdateException(FullGroup group, StateUpdate stateUpdate, Throwable e) { - if (e instanceof IOException) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - } else if (e instanceof EntityNotAvailableException) { - logger.debug("Error while accessing group: {}", e.getMessage(), e); - notifyGroupStatusListeners(group, StatusType.GONE); - } else if (e instanceof ApiException) { - // This should not happen - if it does, it is most likely some bug that should be reported. - logger.warn("Error while accessing group: {}", e.getMessage(), e); - } else if (e instanceof IllegalStateException) { - logger.trace("Error while accessing group: {}", e.getMessage()); - } + private void handleSensorUpdateException(FullSensor sensor, Throwable e) { + handleThingUpdateException("sensor", e, gone -> notifySensorStatusListeners(sensor, gone)); } - private void handleConfigUpdateException(FullSensor sensor, ConfigUpdate configUpdate, Throwable e) { + private void handleThingUpdateException(String thingType, Throwable e, Consumer notifyStatusGone) { if (e instanceof IOException) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); } else if (e instanceof EntityNotAvailableException) { - logger.debug("Error while accessing sensor: {}", e.getMessage(), e); - notifySensorStatusListeners(sensor, StatusType.GONE); + logger.debug("Error while accessing {}: {}", thingType, e.getMessage()); + notifyStatusGone.accept(StatusType.GONE); } else if (e instanceof ApiException) { // This should not happen - if it does, it is most likely some bug that should be reported. - logger.warn("Error while accessing sensor: {}", e.getMessage(), e); + logger.warn("Error while accessing {}: {}", thingType, e.getMessage()); } else if (e instanceof IllegalStateException) { - logger.trace("Error while accessing sensor: {}", e.getMessage()); + logger.trace("Error while accessing {}: {}", thingType, e.getMessage()); } } @@ -728,6 +704,29 @@ public boolean unregisterGroupStatusListener(GroupStatusListener groupStatusList return groupStatusListeners.remove(groupStatusListener); } + /** + * Recall scene to all lights that belong to the scene. + * + * @param id the ID of the scene to activate + */ + @Override + public void recallScene(String id) { + if (hueBridge != null) { + hueBridge.recallScene(id).thenAccept(result -> { + try { + hueBridge.handleErrors(result); + } catch (Exception e) { + logger.debug("Error while recalling scene: {}", e.getMessage()); + } + }).exceptionally(e -> { + logger.debug("Error while recalling scene: {}", e.getMessage()); + return null; + }); + } else { + logger.debug("No bridge connected or selected. Cannot activate scene."); + } + } + @Override public @Nullable FullLight getLightById(String lightId) { return lastLightStates.get(lightId); @@ -778,6 +777,7 @@ public void startSearch(List serialNumbers) { }); } + @Nullable private T withReAuthentication(String taskDescription, Callable runnable) { if (hueBridge != null) { try { @@ -898,7 +898,7 @@ public Collection getConfigStatus() { Collection configStatusMessages; // Check whether an IP address is provided - if (hueBridgeConfig.getIpAddress() == null || hueBridgeConfig.getIpAddress().isEmpty()) { + if (hueBridgeConfig.getIpAddress().isEmpty()) { configStatusMessages = Collections.singletonList(ConfigStatusMessage.Builder.error(HOST) .withMessageKeySuffix(HueConfigStatusMessage.IP_ADDRESS_MISSING).withArguments(HOST).build()); } else { diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueClient.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueClient.java index 1bd3c83d2142a..b332eabcbb425 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueClient.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueClient.java @@ -83,7 +83,7 @@ public interface HueClient { * Get the light by its ID. * * @param lightId the light ID - * @return the full light representation of {@code null} if it could not be found + * @return the full light representation or {@code null} if it could not be found */ @Nullable FullLight getLightById(String lightId); @@ -92,7 +92,7 @@ public interface HueClient { * Get the sensor by its ID. * * @param sensorId the sensor ID - * @return the full sensor representation of {@code null} if it could not be found + * @return the full sensor representation or {@code null} if it could not be found */ @Nullable FullSensor getSensorById(String sensorId); @@ -137,4 +137,11 @@ public interface HueClient { * @param stateUpdate the state update */ void updateGroupState(FullGroup group, StateUpdate stateUpdate); + + /** + * Recall scene to all lights that belong to the scene. + * + * @param id the ID of the scene to be recalled + */ + void recallScene(String id); } diff --git a/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/bridge.xml b/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/bridge.xml index 8c2faf491819c..8f7193c30e701 100644 --- a/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/bridge.xml +++ b/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/bridge.xml @@ -8,6 +8,10 @@ The Hue bridge represents the Philips Hue bridge. + + + + Philips diff --git a/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/channels.xml b/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/channels.xml index d6645d2f43401..aed02888151f4 100644 --- a/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/channels.xml +++ b/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/channels.xml @@ -212,5 +212,11 @@ Flag of CLIP sensor. - + + + + String + + The scene channel allows recalling a scene to all lights that belong to the scene. + From ca955777ec5a1471183216574f3d54cb94abff02 Mon Sep 17 00:00:00 2001 From: leluna Date: Sun, 3 May 2020 17:53:53 +0200 Subject: [PATCH 2/4] Add scene channel to group Signed-off-by: leluna --- .../hue/internal/handler/HueGroupHandler.java | 132 +++++++++--------- .../main/resources/ESH-INF/thing/Group.xml | 1 + 2 files changed, 70 insertions(+), 63 deletions(-) diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueGroupHandler.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueGroupHandler.java index a51e874e27908..427f0682477a2 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueGroupHandler.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueGroupHandler.java @@ -24,6 +24,7 @@ import org.eclipse.smarthome.core.library.types.IncreaseDecreaseType; import org.eclipse.smarthome.core.library.types.OnOffType; import org.eclipse.smarthome.core.library.types.PercentType; +import org.eclipse.smarthome.core.library.types.StringType; import org.eclipse.smarthome.core.thing.Bridge; import org.eclipse.smarthome.core.thing.ChannelUID; import org.eclipse.smarthome.core.thing.Thing; @@ -157,99 +158,105 @@ public void handleCommand(String channel, Command command, long fadeTime) { return; } - StateUpdate groupState = null; + StateUpdate newState = null; switch (channel) { case CHANNEL_COLOR: if (command instanceof HSBType) { HSBType hsbCommand = (HSBType) command; if (hsbCommand.getBrightness().intValue() == 0) { - groupState = LightStateConverter.toOnOffLightState(OnOffType.OFF); + newState = LightStateConverter.toOnOffLightState(OnOffType.OFF); } else { - groupState = LightStateConverter.toColorLightState(hsbCommand, group.getState()); - if (groupState != null) { - groupState.setTransitionTime(fadeTime); - } + newState = LightStateConverter.toColorLightState(hsbCommand, group.getState()); + newState.setTransitionTime(fadeTime); } } else if (command instanceof PercentType) { - groupState = LightStateConverter.toBrightnessLightState((PercentType) command); - if (groupState != null) { - groupState.setTransitionTime(fadeTime); - } + newState = LightStateConverter.toBrightnessLightState((PercentType) command); + newState.setTransitionTime(fadeTime); } else if (command instanceof OnOffType) { - groupState = LightStateConverter.toOnOffLightState((OnOffType) command); + newState = LightStateConverter.toOnOffLightState((OnOffType) command); } else if (command instanceof IncreaseDecreaseType) { - groupState = convertBrightnessChangeToStateUpdate((IncreaseDecreaseType) command, group); - if (groupState != null) { - groupState.setTransitionTime(fadeTime); + newState = convertBrightnessChangeToStateUpdate((IncreaseDecreaseType) command, group); + if (newState != null) { + newState.setTransitionTime(fadeTime); } } break; case CHANNEL_COLORTEMPERATURE: if (command instanceof PercentType) { - groupState = LightStateConverter.toColorTemperatureLightState((PercentType) command); - if (groupState != null) { - groupState.setTransitionTime(fadeTime); - } + newState = LightStateConverter.toColorTemperatureLightState((PercentType) command); + newState.setTransitionTime(fadeTime); } else if (command instanceof OnOffType) { - groupState = LightStateConverter.toOnOffLightState((OnOffType) command); + newState = LightStateConverter.toOnOffLightState((OnOffType) command); } else if (command instanceof IncreaseDecreaseType) { - groupState = convertColorTempChangeToStateUpdate((IncreaseDecreaseType) command, group); - if (groupState != null) { - groupState.setTransitionTime(fadeTime); + newState = convertColorTempChangeToStateUpdate((IncreaseDecreaseType) command, group); + if (newState != null) { + newState.setTransitionTime(fadeTime); } } break; case CHANNEL_BRIGHTNESS: if (command instanceof PercentType) { - groupState = LightStateConverter.toBrightnessLightState((PercentType) command); - if (groupState != null) { - groupState.setTransitionTime(fadeTime); - } + newState = LightStateConverter.toBrightnessLightState((PercentType) command); + newState.setTransitionTime(fadeTime); } else if (command instanceof OnOffType) { - groupState = LightStateConverter.toOnOffLightState((OnOffType) command); + newState = LightStateConverter.toOnOffLightState((OnOffType) command); } else if (command instanceof IncreaseDecreaseType) { - groupState = convertBrightnessChangeToStateUpdate((IncreaseDecreaseType) command, group); - if (groupState != null) { - groupState.setTransitionTime(fadeTime); + newState = convertBrightnessChangeToStateUpdate((IncreaseDecreaseType) command, group); + if (newState != null) { + newState.setTransitionTime(fadeTime); } } - if (groupState != null && lastSentColorTemp != null) { + if (newState != null && lastSentColorTemp != null) { // make sure that the light also has the latest color temp // this might not have been yet set in the light, if it was off - groupState.setColorTemperature(lastSentColorTemp); - groupState.setTransitionTime(fadeTime); + newState.setColorTemperature(lastSentColorTemp); + newState.setTransitionTime(fadeTime); } break; case CHANNEL_SWITCH: if (command instanceof OnOffType) { - groupState = LightStateConverter.toOnOffLightState((OnOffType) command); + newState = LightStateConverter.toOnOffLightState((OnOffType) command); } - if (groupState != null && lastSentColorTemp != null) { + if (newState != null && lastSentColorTemp != null) { // make sure that the light also has the latest color temp // this might not have been yet set in the light, if it was off - groupState.setColorTemperature(lastSentColorTemp); - groupState.setTransitionTime(fadeTime); + newState.setColorTemperature(lastSentColorTemp); + newState.setTransitionTime(fadeTime); + } + break; + case CHANNEL_SCENE: + if (command instanceof StringType) { + newState = new StateUpdate().setScene(command.toString()); } break; default: break; } - if (groupState != null) { - // Cache values which we have sent - Integer tmpBrightness = groupState.getBrightness(); - if (tmpBrightness != null) { - lastSentBrightness = tmpBrightness; - } - Integer tmpColorTemp = groupState.getColorTemperature(); - if (tmpColorTemp != null) { - lastSentColorTemp = tmpColorTemp; - } - bridgeHandler.updateGroupState(group, groupState); + if (newState != null) { + cacheNewState(newState); + bridgeHandler.updateGroupState(group, newState); } else { logger.debug("Command sent to an unknown channel id: {}:{}", getThing().getUID(), channel); } } + /** + * Caches the new state that is sent to the bridge. This is necessary in case the lights are off when the values are + * sent. In this case, the values are not yet set in the lights. + * + * @param newState the state to be cached + */ + private void cacheNewState(StateUpdate newState) { + Integer tmpBrightness = newState.getBrightness(); + if (tmpBrightness != null) { + lastSentBrightness = tmpBrightness; + } + Integer tmpColorTemp = newState.getColorTemperature(); + if (tmpColorTemp != null) { + lastSentColorTemp = tmpColorTemp; + } + } + private @Nullable StateUpdate convertColorTempChangeToStateUpdate(IncreaseDecreaseType command, FullGroup group) { StateUpdate stateUpdate = null; Integer currentColorTemp = getCurrentColorTemp(group.getState()); @@ -269,25 +276,24 @@ public void handleCommand(String channel, Command command, long fadeTime) { } private @Nullable StateUpdate convertBrightnessChangeToStateUpdate(IncreaseDecreaseType command, FullGroup group) { - StateUpdate stateUpdate = null; - Integer currentBrightness = getCurrentBrightness(group.getState()); - if (currentBrightness != null) { - int newBrightness = LightStateConverter.toAdjustedBrightness(command, currentBrightness); - stateUpdate = createBrightnessStateUpdate(currentBrightness, newBrightness); + Integer currentBrightness = getCurrentBrightness(group); + if (currentBrightness == null) { + return null; } - return stateUpdate; + int newBrightness = LightStateConverter.toAdjustedBrightness(command, currentBrightness); + return createBrightnessStateUpdate(currentBrightness, newBrightness); } - private @Nullable Integer getCurrentBrightness(@Nullable State groupState) { - Integer brightness = lastSentBrightness; - if (brightness == null && groupState != null) { - if (!groupState.isOn()) { - brightness = 0; - } else { - brightness = groupState.getBrightness(); - } + private @Nullable Integer getCurrentBrightness(FullGroup group) { + if (lastSentBrightness != null) { + return lastSentBrightness; + } + + State currentState = group.getState(); + if (currentState == null) { + return null; } - return brightness; + return currentState.isOn() ? currentState.getBrightness() : 0; } private StateUpdate createBrightnessStateUpdate(int currentBrightness, int newBrightness) { diff --git a/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/Group.xml b/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/Group.xml index a19f10151f122..7add537e96867 100644 --- a/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/Group.xml +++ b/bundles/org.openhab.binding.hue/src/main/resources/ESH-INF/thing/Group.xml @@ -17,6 +17,7 @@ + groupId From 13dda0002c551bf4b8f5424acafba4bfd6c115a6 Mon Sep 17 00:00:00 2001 From: leluna Date: Tue, 5 May 2020 16:04:33 +0200 Subject: [PATCH 3/4] Dynamic state options for scene channel of the bridge Signed-off-by: leluna --- .../binding/hue/internal/HueBridge.java | 22 ++++ .../hue/internal/HueThingHandlerFactory.java | 16 ++- .../openhab/binding/hue/internal/Scene.java | 103 ++++++++++++++++++ .../internal/handler/HueBridgeHandler.java | 68 +++++++++--- .../HueStateDescriptionOptionProvider.java | 42 +++++++ 5 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java create mode 100644 bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueStateDescriptionOptionProvider.java diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java index 99b7db7d115a1..cb60b099bbce9 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java @@ -28,6 +28,7 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -851,6 +852,27 @@ public void deleteSchedule(Schedule schedule) throws IOException, ApiException { handleErrors(result); } + /** + * Returns the list of scenes that are not recyclable. + * + * @return all scenes that can be activated + */ + public List getScenes() throws IOException, ApiException { + requireAuthentication(); + + Result result = http.get(getRelativeURL("scenes")); + handleErrors(result); + + Map sceneMap = safeFromJson(result.getBody(), Scene.GSON_TYPE); + return sceneMap.entrySet().parallelStream()// + .map(e -> { + e.getValue().setId(e.getKey()); + return e.getValue(); + })// + .filter(scene -> !scene.isRecycle())// + .collect(Collectors.toList()); + } + /** * Activate scene to all lights that belong to the scene. * diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java index 2073cf59d57fc..7e8d84720a707 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java @@ -37,6 +37,7 @@ import org.openhab.binding.hue.internal.handler.HueBridgeHandler; import org.openhab.binding.hue.internal.handler.HueGroupHandler; import org.openhab.binding.hue.internal.handler.HueLightHandler; +import org.openhab.binding.hue.internal.handler.HueStateDescriptionOptionProvider; import org.openhab.binding.hue.internal.handler.sensors.ClipHandler; import org.openhab.binding.hue.internal.handler.sensors.DimmerSwitchHandler; import org.openhab.binding.hue.internal.handler.sensors.LightLevelHandler; @@ -45,6 +46,7 @@ import org.openhab.binding.hue.internal.handler.sensors.TemperatureHandler; import org.osgi.framework.ServiceRegistration; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; /** * {@link HueThingHandlerFactory} is a factory for {@link HueBridgeHandler}s. @@ -69,6 +71,9 @@ public class HueThingHandlerFactory extends BaseThingHandlerFactory { private final Map> discoveryServiceRegs = new HashMap<>(); + @NonNullByDefault({}) + private HueStateDescriptionOptionProvider stateOptionProvider; + @Override public @Nullable Thing createThing(ThingTypeUID thingTypeUID, Configuration configuration, @Nullable ThingUID thingUID, @Nullable ThingUID bridgeUID) { @@ -136,7 +141,7 @@ private ThingUID getThingUID(ThingTypeUID thingTypeUID, String id, @Nullable Thi @Override protected @Nullable ThingHandler createHandler(Thing thing) { if (HueBridgeHandler.SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())) { - HueBridgeHandler handler = new HueBridgeHandler((Bridge) thing); + HueBridgeHandler handler = new HueBridgeHandler((Bridge) thing, stateOptionProvider); registerLightDiscoveryService(handler); return handler; } else if (HueLightHandler.SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())) { @@ -182,4 +187,13 @@ protected synchronized void removeHandler(ThingHandler thingHandler) { } } } + + @Reference + protected void setHueStateDescriptionOptionProvider(HueStateDescriptionOptionProvider stateOptionProvider) { + this.stateOptionProvider = stateOptionProvider; + } + + protected void unsetHueStateDescriptionOptionProvider(HueStateDescriptionOptionProvider stateOptionProvider) { + this.stateOptionProvider = null; + } } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java new file mode 100644 index 0000000000000..b60a66f21f0b9 --- /dev/null +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java @@ -0,0 +1,103 @@ +/** + * 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.hue.internal; + +import java.lang.reflect.Type; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.smarthome.core.types.StateOption; + +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; + +/** + * Basic scene information. + * + * @author Hengrui Jiang - Initial contribution + */ +public class Scene { + public static final Type GSON_TYPE = new TypeToken>() { + }.getType(); + + private String id; + private String name; + @SerializedName("lights") + private List lightIds; + @SerializedName("group") + private String groupId; + private boolean recycle; + + @NonNull + public String getId() { + return id; + } + + void setId(String id) { + this.id = id; + } + + /** + * Returns the human readable name of the scene. If the name is omitted upon creation, this + * defaults to the ID. + * + * @return human readable name of the scene + */ + @NonNull + public String getName() { + return name; + } + + /** + * Returns the list of lights that the scene applies to. For group scenes, this list should be identical to the list + * of all lights that are in the group. + * + * @return list of lights that the scene applies to + */ + @NonNull + public List getLightIds() { + return lightIds; + } + + /** + * Returns the group that the scene belongs to. This field is optional for scenes that applies to a specific list of + * lights instead of a group. + * + * @return the group that the scene belongs to + */ + public String getGroupId() { + return groupId; + } + + /** + * Indicates if the scene can be recycled by the bridge. A recyclable scene is not able to be activated. + * + * @return whether the scene can be recycled + */ + public boolean isRecycle() { + return recycle; + } + + public StateOption toStateOption(Map groupNames) { + StringBuilder stateOptionLabel = new StringBuilder(name); + if (groupId != null && groupNames.containsKey(groupId)) { + stateOptionLabel.append(" (").append(groupNames.get(groupId)).append(")"); + } + if (!id.contentEquals(name)) { + stateOptionLabel.append(" [").append(id).append("]"); + } + + return new StateOption(id, stateOptionLabel.toString()); + } +} diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java index 351aed05647a6..6c2c789afc13b 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java @@ -30,6 +30,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -45,6 +46,7 @@ import org.eclipse.smarthome.core.thing.ThingTypeUID; import org.eclipse.smarthome.core.thing.binding.ConfigStatusBridgeHandler; import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.core.types.StateOption; import org.openhab.binding.hue.internal.ApiVersionUtils; import org.openhab.binding.hue.internal.Config; import org.openhab.binding.hue.internal.ConfigUpdate; @@ -96,6 +98,7 @@ private static enum StatusType { } private final Logger logger = LoggerFactory.getLogger(HueBridgeHandler.class); + private final @NonNullByDefault({}) HueStateDescriptionOptionProvider stateDescriptionOptionProvider; private final Map lastLightStates = new ConcurrentHashMap<>(); private final Map lastSensorStates = new ConcurrentHashMap<>(); @@ -145,8 +148,6 @@ public void run() { } } - protected abstract void doConnectedRun() throws IOException, ApiException; - private boolean isReachable(String ipAddress) { try { // note that InetAddress.isReachable is unreliable, see @@ -169,6 +170,8 @@ private boolean isReachable(String ipAddress) { } return true; } + + protected abstract void doConnectedRun() throws IOException, ApiException; } private final Runnable sensorPollingRunnable = new PollingRunnable() { @@ -205,6 +208,11 @@ protected void doConnectedRun() throws IOException, ApiException { private final Runnable lightPollingRunnable = new PollingRunnable() { @Override protected void doConnectedRun() throws IOException, ApiException { + updateLights(); + updateGroups(); + } + + private void updateLights() throws IOException, ApiException { Map lastLightStateCopy = new HashMap<>(lastLightStates); List lights; @@ -214,19 +222,19 @@ protected void doConnectedRun() throws IOException, ApiException { lights = hueBridge.getFullConfig().getLights(); } - for (final FullLight fullLight : lights) { - final String lightId = fullLight.getId(); - lastLightStates.put(lightId, fullLight); + for (final FullLight uptodateFullLight : lights) { + final String lightId = uptodateFullLight.getId(); + lastLightStates.put(lightId, uptodateFullLight); if (lastLightStateCopy.containsKey(lightId)) { - final FullLight lastFullLight = lastLightStateCopy.remove(lightId); - final State lastFullLightState = lastFullLight.getState(); - if (!lastFullLightState.equals(fullLight.getState())) { + final FullLight oldFullLight = lastLightStateCopy.remove(lightId); + final State oldFullLightState = oldFullLight.getState(); + if (!oldFullLightState.equals(uptodateFullLight.getState())) { logger.debug("Status update for Hue light '{}' detected.", lightId); - notifyLightStatusListeners(fullLight, StatusType.CHANGED); + notifyLightStatusListeners(uptodateFullLight, StatusType.CHANGED); } } else { logger.debug("Hue light '{}' added.", lightId); - notifyLightStatusListeners(fullLight, StatusType.ADDED); + notifyLightStatusListeners(uptodateFullLight, StatusType.ADDED); } } @@ -236,7 +244,9 @@ protected void doConnectedRun() throws IOException, ApiException { logger.debug("Hue light '{}' removed.", fullLightEntry.getKey()); notifyLightStatusListeners(fullLightEntry.getValue(), StatusType.REMOVED); } + } + private void updateGroups() throws IOException, ApiException { Map lastGroupStateCopy = new HashMap<>(lastGroupStates); for (final FullGroup fullGroup : hueBridge.getGroups()) { @@ -247,8 +257,8 @@ protected void doConnectedRun() throws IOException, ApiException { State colorRef = null; HSBType firstColorHsb = null; for (String lightId : fullGroup.getLights()) { - FullLight light = lastLightStates.get(lightId); - if (light != null) { + if (lastLightStates.containsKey(lightId)) { + FullLight light = lastLightStates.get(lightId); final State lightState = light.getState(); logger.trace("Group {}: light {}: on {} bri {} hue {} sat {} temp {} mode {} XY {}", fullGroup.getName(), light.getName(), lightState.isOn(), lightState.getBrightness(), @@ -309,8 +319,22 @@ protected void doConnectedRun() throws IOException, ApiException { } }; + private final Runnable scenePollingRunnable = new PollingRunnable() { + @Override + protected void doConnectedRun() throws IOException, ApiException { + Map groupNames = lastGroupStates.entrySet().parallelStream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getName())); + List stateOptions = hueBridge.getScenes().parallelStream()// + .map(scene -> scene.toStateOption(groupNames))// + .collect(Collectors.toList()); + stateDescriptionOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SCENE), + stateOptions); + } + }; + private long lightPollingInterval = TimeUnit.SECONDS.toSeconds(10); private long sensorPollingInterval = TimeUnit.MILLISECONDS.toMillis(500); + private long scenePollingInterval = TimeUnit.MILLISECONDS.convert(10, TimeUnit.MINUTES); private boolean lastBridgeConnectionState = false; @@ -318,12 +342,14 @@ protected void doConnectedRun() throws IOException, ApiException { private @Nullable ScheduledFuture lightPollingJob; private @Nullable ScheduledFuture sensorPollingJob; + private @Nullable ScheduledFuture scenePollingJob; private @NonNullByDefault({}) HueBridge hueBridge = null; private @NonNullByDefault({}) HueBridgeConfig hueBridgeConfig = null; - public HueBridgeHandler(Bridge bridge) { + public HueBridgeHandler(Bridge bridge, HueStateDescriptionOptionProvider stateDescriptionOptionProvider) { super(bridge); + this.stateDescriptionOptionProvider = stateDescriptionOptionProvider; } @Override @@ -482,11 +508,26 @@ private void stopSensorPolling() { } } + private void startScenePolling() { + if (scenePollingJob == null || scenePollingJob.isCancelled()) { + scenePollingJob = scheduler.scheduleWithFixedDelay(scenePollingRunnable, 5000, scenePollingInterval, + TimeUnit.MILLISECONDS); + } + } + + private void stopScenePolling() { + if (scenePollingJob != null && !scenePollingJob.isCancelled()) { + scenePollingJob.cancel(true); + scenePollingJob = null; + } + } + @Override public void dispose() { logger.debug("Handler disposed."); stopLightPolling(); stopSensorPolling(); + stopScenePolling(); if (hueBridge != null) { hueBridge = null; } @@ -514,6 +555,7 @@ private synchronized void onUpdate() { if (hueBridge != null) { startLightPolling(); startSensorPolling(); + startScenePolling(); } } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueStateDescriptionOptionProvider.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueStateDescriptionOptionProvider.java new file mode 100644 index 0000000000000..51018cd4e9324 --- /dev/null +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueStateDescriptionOptionProvider.java @@ -0,0 +1,42 @@ +/** + * 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.hue.internal.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.core.thing.binding.BaseDynamicStateDescriptionProvider; +import org.eclipse.smarthome.core.thing.i18n.ChannelTypeI18nLocalizationService; +import org.eclipse.smarthome.core.thing.type.DynamicStateDescriptionProvider; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * Dynamic provider of state options for {@link HueBridgeHandler} while leaving other state description fields as + * original. + * + * @author Hengrui Jiang - Initial contribution + */ +@Component(service = { DynamicStateDescriptionProvider.class, HueStateDescriptionOptionProvider.class }) +@NonNullByDefault +public class HueStateDescriptionOptionProvider extends BaseDynamicStateDescriptionProvider { + + @Reference + protected void setChannelTypeI18nLocalizationService( + final ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) { + this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService; + } + + protected void unsetChannelTypeI18nLocalizationService( + final ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) { + this.channelTypeI18nLocalizationService = null; + } +} From e74bb89bb470a839ff106c10cd7c00551ad7943c Mon Sep 17 00:00:00 2001 From: leluna Date: Mon, 18 May 2020 15:20:38 +0200 Subject: [PATCH 4/4] Add dynamic state options to group scene channel Signed-off-by: leluna --- bundles/org.openhab.binding.hue/README.md | 4 +- .../binding/hue/internal/FullGroup.java | 11 ++ .../openhab/binding/hue/internal/Group.java | 9 ++ .../binding/hue/internal/HueBridge.java | 13 +++ .../hue/internal/HueThingHandlerFactory.java | 2 +- .../openhab/binding/hue/internal/Scene.java | 61 +++++++++- .../discovery/HueLightDiscoveryService.java | 6 + .../internal/handler/GroupStatusListener.java | 11 ++ .../internal/handler/HueBridgeHandler.java | 19 ++- .../hue/internal/handler/HueGroupHandler.java | 21 +++- .../binding/hue/internal/HueBridgeTest.java | 99 ++++++++++++++++ .../binding/hue/internal/SceneTest.java | 108 ++++++++++++++++++ 12 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/HueBridgeTest.java create mode 100644 bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/SceneTest.java diff --git a/bundles/org.openhab.binding.hue/README.md b/bundles/org.openhab.binding.hue/README.md index a349aa83ba4a5..994f6214cda40 100644 --- a/bundles/org.openhab.binding.hue/README.md +++ b/bundles/org.openhab.binding.hue/README.md @@ -232,9 +232,7 @@ The bridge itself supports a channel to activate scenes: | --------------- | --------- | ----------- | | scene | String | This channel activates the scene with the given ID String. | -The scenes are identified by an ID String that is assigned by the Hue bridge. These must be aquired directly from [Hue REST API](https://developers.meethue.com/develop/hue-api/). - -This channel can then be used in the sitemap, for example as a switch or selection. +The scenes are identified by an ID String that is assigned by the Hue bridge. ## Rule Actions diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/FullGroup.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/FullGroup.java index f51b8974df21e..3492dcbba808f 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/FullGroup.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/FullGroup.java @@ -34,6 +34,17 @@ public class FullGroup extends Group { private State state; // Will not be set by hue API FullGroup() { + super(); + } + + /** + * Test constructor + */ + FullGroup(String id, String name, String type, State action, List lights, State state) { + super(id, name, type); + this.action = action; + this.lights = lights; + this.state = state; } /** diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Group.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Group.java index f921af7a5bc6a..b841c42d42b2c 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Group.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Group.java @@ -30,6 +30,15 @@ public class Group { this.type = "LightGroup"; } + /** + * Test constructor + */ + Group(String id, String name, String type) { + this.id = id; + this.name = name; + this.type = type; + } + void setName(String name) { this.name = name; } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java index cb60b099bbce9..d2ae873e6ca8f 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBridge.java @@ -22,6 +22,7 @@ import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.Map; @@ -115,6 +116,17 @@ public HueBridge(String ip, int port, String protocol, String username, Schedule authenticate(username); } + /** + * Test constructor + */ + HueBridge(String ip, String baseUrl, String username, ScheduledExecutorService scheduler, HttpClient http) { + this.ip = ip; + this.baseUrl = baseUrl; + this.username = username; + this.scheduler = scheduler; + this.http = http; + } + /** * Set the connect and read timeout for HTTP requests. * @@ -870,6 +882,7 @@ public List getScenes() throws IOException, ApiException { return e.getValue(); })// .filter(scene -> !scene.isRecycle())// + .sorted(Comparator.comparing(Scene::getGroupId))// .collect(Collectors.toList()); } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java index 7e8d84720a707..1441dd9655ff5 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueThingHandlerFactory.java @@ -159,7 +159,7 @@ private ThingUID getThingUID(ThingTypeUID thingTypeUID, String id, @Nullable Thi } else if (ClipHandler.SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())) { return new ClipHandler(thing); } else if (HueGroupHandler.SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())) { - return new HueGroupHandler(thing); + return new HueGroupHandler(thing, stateOptionProvider); } else { return null; } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java index b60a66f21f0b9..9093b06299b9e 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java @@ -39,6 +39,24 @@ public class Scene { private String groupId; private boolean recycle; + /** + * Default constructor for GSon. + */ + public Scene() { + super(); + } + + /** + * Test constructor + */ + Scene(String id, String name, String groupId, List lightIds, boolean recycle) { + this.id = id; + this.name = name; + this.groupId = groupId; + this.lightIds = lightIds; + this.recycle = recycle; + } + @NonNull public String getId() { return id; @@ -89,15 +107,52 @@ public boolean isRecycle() { return recycle; } + /** + * Creates a {@link StateOption} to display this scene, including the group that it belongs to. + *

+ * The display name is built with the following pattern: + *

    + *
  1. Human readable name of the scene if set. Otherwise, the ID is displayed
  2. + *
  3. Group for which the scene is defined
  4. + *
+ */ public StateOption toStateOption(Map groupNames) { StringBuilder stateOptionLabel = new StringBuilder(name); if (groupId != null && groupNames.containsKey(groupId)) { stateOptionLabel.append(" (").append(groupNames.get(groupId)).append(")"); } - if (!id.contentEquals(name)) { - stateOptionLabel.append(" [").append(id).append("]"); - } return new StateOption(id, stateOptionLabel.toString()); } + + /** + * Creates a {@link StateOption} to display this scene. + */ + public StateOption toStateOption() { + return new StateOption(id, name); + } + + /** + * Returns whether the scene is applicable to the given group. + *

+ * According to the hue API, a scene is applicable to a group if either + *

    + *
  1. The scene is defined for the group
  2. + *
  3. All lights of the scene also belong to the group
  4. + *
+ */ + public boolean isApplicableTo(FullGroup group) { + if (getGroupId() == null) { + return getLightIds().parallelStream()// + .allMatch(id -> group.getLights().contains(id)); + } else { + return group.getId().contentEquals(getGroupId()); + } + } + + @Override + public String toString() { + return String.format("{Scene name: %s; id: %s; lightIds: %s; groupId: %s; recycle: %s}", name, id, lightIds, + groupId, recycle); + } } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueLightDiscoveryService.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueLightDiscoveryService.java index 161343b9ec928..cfa47af091d5d 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueLightDiscoveryService.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueLightDiscoveryService.java @@ -37,6 +37,7 @@ import org.openhab.binding.hue.internal.FullLight; import org.openhab.binding.hue.internal.FullSensor; import org.openhab.binding.hue.internal.HueBridge; +import org.openhab.binding.hue.internal.Scene; import org.openhab.binding.hue.internal.handler.GroupStatusListener; import org.openhab.binding.hue.internal.handler.HueBridgeHandler; import org.openhab.binding.hue.internal.handler.HueGroupHandler; @@ -326,4 +327,9 @@ public void onGroupStateChanged(@Nullable HueBridge bridge, FullGroup group) { // nothing to do } + @Override + public void onScenesUpdated(@Nullable HueBridge bridge, List scenes) { + // nothing to do + } + } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/GroupStatusListener.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/GroupStatusListener.java index cccc27840134e..a0c2558d38deb 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/GroupStatusListener.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/GroupStatusListener.java @@ -12,10 +12,13 @@ */ package org.openhab.binding.hue.internal.handler; +import java.util.List; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.hue.internal.FullGroup; import org.openhab.binding.hue.internal.HueBridge; +import org.openhab.binding.hue.internal.Scene; /** * The {@link GroupStatusListener} is notified when a group status has changed or a group has been removed or added. @@ -57,4 +60,12 @@ public interface GroupStatusListener { * @param group The added group */ void onGroupAdded(@Nullable HueBridge bridge, FullGroup group); + + /** + * This method is called whenever the list of available scenes is updated. + * + * @param bridge The bridge on which all scenes is stored + * @param updatedScenes available scenes + */ + void onScenesUpdated(@Nullable HueBridge bridge, List scenes); } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java index 6c2c789afc13b..d18f05e2f5ce0 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java @@ -56,6 +56,7 @@ import org.openhab.binding.hue.internal.FullSensor; import org.openhab.binding.hue.internal.HueBridge; import org.openhab.binding.hue.internal.HueConfigStatusMessage; +import org.openhab.binding.hue.internal.Scene; import org.openhab.binding.hue.internal.State; import org.openhab.binding.hue.internal.StateUpdate; import org.openhab.binding.hue.internal.config.HueBridgeConfig; @@ -98,7 +99,7 @@ private static enum StatusType { } private final Logger logger = LoggerFactory.getLogger(HueBridgeHandler.class); - private final @NonNullByDefault({}) HueStateDescriptionOptionProvider stateDescriptionOptionProvider; + private final HueStateDescriptionOptionProvider stateDescriptionOptionProvider; private final Map lastLightStates = new ConcurrentHashMap<>(); private final Map lastSensorStates = new ConcurrentHashMap<>(); @@ -322,9 +323,17 @@ private void updateGroups() throws IOException, ApiException { private final Runnable scenePollingRunnable = new PollingRunnable() { @Override protected void doConnectedRun() throws IOException, ApiException { - Map groupNames = lastGroupStates.entrySet().parallelStream() + List scenes = hueBridge.getScenes(); + logger.trace("Scenes detected: {}", scenes); + + setBridgeSceneChannelStateOptions(scenes, lastGroupStates); + notifyGroupSceneUpdate(scenes); + } + + private void setBridgeSceneChannelStateOptions(List scenes, Map groups) { + Map groupNames = groups.entrySet().parallelStream() .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getName())); - List stateOptions = hueBridge.getScenes().parallelStream()// + List stateOptions = scenes.parallelStream()// .map(scene -> scene.toStateOption(groupNames))// .collect(Collectors.toList()); stateDescriptionOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SCENE), @@ -934,6 +943,10 @@ private void notifyGroupStatusListeners(final FullGroup fullGroup, StatusType ty } } + private void notifyGroupSceneUpdate(List scenes) { + groupStatusListeners.forEach(l -> l.onScenesUpdated(hueBridge, scenes)); + } + @Override public Collection getConfigStatus() { // The bridge IP address to be used for checks diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueGroupHandler.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueGroupHandler.java index 427f0682477a2..a5a2cf28997c3 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueGroupHandler.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueGroupHandler.java @@ -16,7 +16,9 @@ import java.math.BigDecimal; import java.util.Collections; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -35,9 +37,11 @@ import org.eclipse.smarthome.core.thing.binding.BaseThingHandler; import org.eclipse.smarthome.core.thing.binding.ThingHandler; import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.core.types.StateOption; import org.eclipse.smarthome.core.types.UnDefType; import org.openhab.binding.hue.internal.FullGroup; import org.openhab.binding.hue.internal.HueBridge; +import org.openhab.binding.hue.internal.Scene; import org.openhab.binding.hue.internal.State; import org.openhab.binding.hue.internal.State.ColorMode; import org.openhab.binding.hue.internal.StateUpdate; @@ -55,6 +59,7 @@ public class HueGroupHandler extends BaseThingHandler implements GroupStatusList public static final Set SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_GROUP); private final Logger logger = LoggerFactory.getLogger(HueGroupHandler.class); + private final HueStateDescriptionOptionProvider stateDescriptionOptionProvider; private @NonNullByDefault({}) String groupId; @@ -65,8 +70,9 @@ public class HueGroupHandler extends BaseThingHandler implements GroupStatusList private @Nullable HueClient hueClient; - public HueGroupHandler(Thing thing) { + public HueGroupHandler(Thing thing, HueStateDescriptionOptionProvider stateDescriptionOptionProvider) { super(thing); + this.stateDescriptionOptionProvider = stateDescriptionOptionProvider; } @Override @@ -383,4 +389,17 @@ public void onGroupGone(@Nullable HueBridge bridge, FullGroup group) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/offline.group-removed"); } } + + /** + * Sets the state options for applicable scenes. + */ + @Override + public void onScenesUpdated(@Nullable HueBridge bridge, List updatedScenes) { + List stateOptions = updatedScenes.parallelStream()// + .filter(scene -> scene.isApplicableTo(getHueClient().getGroupById(groupId)))// + .map(Scene::toStateOption)// + .collect(Collectors.toList()); + stateDescriptionOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SCENE), + stateOptions); + } } diff --git a/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/HueBridgeTest.java b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/HueBridgeTest.java new file mode 100644 index 0000000000000..b9e4005bcdaad --- /dev/null +++ b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/HueBridgeTest.java @@ -0,0 +1,99 @@ +/** + * 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.hue.internal; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import org.junit.Test; +import org.mockito.Mockito; +import org.openhab.binding.hue.internal.HttpClient.Result; +import org.openhab.binding.hue.internal.exceptions.ApiException; + +/** + * @author Hengrui Jiang - initial contribution + */ +public class HueBridgeTest { + + @Test + public void testGetScenes_ExcludeRecycleScenes() throws IOException, ApiException { + HttpClient mockHttpClient = Mockito.mock(HttpClient.class); + + HueBridge hueBridge = new HueBridge("ip", "baseUrl", "username", Executors.newScheduledThreadPool(1), + mockHttpClient); + + List testScenes = Arrays.asList(new Scene("id1", "name1", "group1", Collections.emptyList(), true), // + new Scene("id2", "name2", "group2", Collections.emptyList(), false)); + when(mockHttpClient.get("baseUrl/username/scenes")).thenReturn(new Result(createMockResponse(testScenes), 200)); + + List scenes = hueBridge.getScenes(); + assertThat(scenes.size(), is(1)); + assertThat(scenes.get(0).getId(), is("id2")); + } + + @Test + public void testGetScenes_OrderByGroup() throws IOException, ApiException { + HttpClient mockHttpClient = Mockito.mock(HttpClient.class); + + HueBridge hueBridge = new HueBridge("ip", "baseUrl", "username", Executors.newScheduledThreadPool(1), + mockHttpClient); + + List testScenes = Arrays.asList(new Scene("id1", "name1", "group1", Collections.emptyList(), false), // + new Scene("id2", "name2", "group2", Collections.emptyList(), false), + new Scene("id3", "name3", "group1", Collections.emptyList(), false)); + when(mockHttpClient.get("baseUrl/username/scenes")).thenReturn(new Result(createMockResponse(testScenes), 200)); + + List scenes = hueBridge.getScenes(); + assertThat(scenes.size(), is(3)); + assertThat(scenes.get(0).getId(), is("id1")); + assertThat(scenes.get(1).getId(), is("id3")); + assertThat(scenes.get(2).getId(), is("id2")); + } + + private static String createMockResponse(List scenes) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("{"); + stringBuilder.append(scenes.stream().map(HueBridgeTest::createMockJson).collect(Collectors.joining(","))); + stringBuilder.append("\n}"); + return stringBuilder.toString(); + } + + private static String createMockJson(Scene scene) { + // Sample response for getting scenes taken from hue API documentation. + // Extended with the attribute "group" + String template = "" + // + " \"%s\": {\n" + // + " \"name\": \"%s\",\n" + // + " \"lights\": [%s],\n" + // + " \"owner\": \"ffffffffe0341b1b376a2389376a2389\",\n" + // + " \"recycle\": %s,\n" + // + " \"locked\": false,\n" + // + " \"appdata\": {},\n" + // + " \"picture\": \"\",\n" + // + " \"lastupdated\": \"2015-12-03T08:57:13\",\n" + // + " \"version\": 2,\n" + // + " \"group\": \"%s\"\n" + // + " }"; + String lights = String.join(",", + scene.getLightIds().stream().map(id -> "\"" + id + "\"").collect(Collectors.toList())); + return String.format(template, scene.getId(), scene.getName(), lights, scene.isRecycle(), scene.getGroupId()); + } +} diff --git a/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/SceneTest.java b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/SceneTest.java new file mode 100644 index 0000000000000..2782b9c054ff9 --- /dev/null +++ b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/SceneTest.java @@ -0,0 +1,108 @@ +/** + * 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.hue.internal; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; + +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +/** + * @author HJiang - initial contribution + */ +public class SceneTest { + + private static final State PLACEHOLDER_STATE = new State(); + private static final String PLACEHOLDER = "placeholder"; + + /** + * If a scene already has a group ID, it should applicable to the group with the given ID. + */ + @Test + public void testIsApplicableTo_HasGroupId_MatchingGroup() { + String groupId = "groupId"; + List lights = Arrays.asList("1", "2"); + + Scene scene = new Scene(PLACEHOLDER, PLACEHOLDER, groupId, lights, false); + FullGroup group = new FullGroup(groupId, PLACEHOLDER, PLACEHOLDER, PLACEHOLDER_STATE, lights, + PLACEHOLDER_STATE); + + assertThat(scene.isApplicableTo(group), is(true)); + } + + /** + * If a scene already has a group ID, it should be NOT applicable to a group with different ID even if the lights + * match. + */ + @Test + public void testIsApplicableTo_HasGroupId_NotMatchingGroup() { + String groupId = "groupId"; + String otherGroupId = "otherGroupId"; + List lights = Arrays.asList("1", "2"); + List otherLights = Arrays.asList("1", "2", "3"); + + Scene scene = new Scene(PLACEHOLDER, PLACEHOLDER, groupId, lights, false); + + FullGroup nonMatchingGroupWithOtherLights = new FullGroup(otherGroupId, PLACEHOLDER, PLACEHOLDER, + PLACEHOLDER_STATE, otherLights, PLACEHOLDER_STATE); + assertThat(scene.isApplicableTo(nonMatchingGroupWithOtherLights), is(false)); + + FullGroup nonMatchingGroupWithSameLights = new FullGroup(otherGroupId, PLACEHOLDER, PLACEHOLDER, + PLACEHOLDER_STATE, lights, PLACEHOLDER_STATE); + assertThat(scene.isApplicableTo(nonMatchingGroupWithSameLights), is(false)); + } + + /** + * If a scene does not have a group ID, it should be applicable to a group that contains all lights of the + * scene. + */ + @Test + public void testIsApplicableTo_NoGroupId_SceneLightsContainedInGroup() { + List lights = Arrays.asList("1", "2"); + List moreLights = Arrays.asList("1", "2", "3"); + + Scene scene = new Scene(PLACEHOLDER, PLACEHOLDER, null, lights, false); + + FullGroup groupWithAllLights = new FullGroup("groupId", PLACEHOLDER, PLACEHOLDER, PLACEHOLDER_STATE, lights, + PLACEHOLDER_STATE); + assertThat(scene.isApplicableTo(groupWithAllLights), is(true)); + + FullGroup groupWithMoreLights = new FullGroup("otherGroupId", PLACEHOLDER, PLACEHOLDER, PLACEHOLDER_STATE, + moreLights, PLACEHOLDER_STATE); + assertThat(scene.isApplicableTo(groupWithMoreLights), is(true)); + } + + /** + * If a scene does not have a group ID, it should be NOT applicable to a group that does not contain all lights of + * the scene. + */ + @Test + public void testIsApplicableTo_NoGroupId_SceneLightsNotContainedInGroup() { + List lights = Arrays.asList("1", "2"); + List lessLights = Arrays.asList("1"); + List differentLights = Arrays.asList("3"); + + Scene scene = new Scene(PLACEHOLDER, PLACEHOLDER, null, lights, false); + + FullGroup groupWithLessLights = new FullGroup("groupId", PLACEHOLDER, PLACEHOLDER, PLACEHOLDER_STATE, + lessLights, PLACEHOLDER_STATE); + assertThat(scene.isApplicableTo(groupWithLessLights), is(false)); + + FullGroup groupWithDifferentLights = new FullGroup("otherGroupId", PLACEHOLDER, PLACEHOLDER, PLACEHOLDER_STATE, + differentLights, PLACEHOLDER_STATE); + assertThat(scene.isApplicableTo(groupWithDifferentLights), is(false)); + } +}