From fbf0aaf1015ddc136917865095fdb71ec92585e4 Mon Sep 17 00:00:00 2001 From: lolodomo Date: Mon, 13 Jul 2020 23:43:50 +0200 Subject: [PATCH] [hue] Add support for hue scene activation (#8098) * [hue] Add support for hue scene activation Closes #6043 This is the continuation of the PR #7540 95% of credits go to leluna Signed-off-by: Laurent Garnier Also-by: leluna --- bundles/org.openhab.binding.hue/README.md | 14 +- .../binding/hue/internal/FullGroup.java | 13 +- .../openhab/binding/hue/internal/Group.java | 9 + .../hue/internal/HueBindingConstants.java | 3 +- .../binding/hue/internal/HueBridge.java | 46 ++++ .../binding/hue/internal/HueObject.java | 2 +- .../hue/internal/HueThingHandlerFactory.java | 15 +- .../openhab/binding/hue/internal/Scene.java | 160 +++++++++++++ .../binding/hue/internal/StateUpdate.java | 11 + .../internal/console/HueCommandExtension.java | 30 ++- .../discovery/HueLightDiscoveryService.java | 2 +- .../internal/handler/GroupStatusListener.java | 12 +- .../internal/handler/HueBridgeHandler.java | 212 ++++++++++++------ .../hue/internal/handler/HueClient.java | 17 +- .../hue/internal/handler/HueGroupHandler.java | 166 +++++++++----- .../HueStateDescriptionOptionProvider.java | 42 ++++ .../main/resources/ESH-INF/thing/Group.xml | 1 + .../main/resources/ESH-INF/thing/bridge.xml | 4 + .../main/resources/ESH-INF/thing/channels.xml | 6 + .../binding/hue/internal/HueBridgeTest.java | 99 ++++++++ .../binding/hue/internal/SceneTest.java | 108 +++++++++ .../internal/handler/HueLightHandlerTest.java | 8 +- 22 files changed, 835 insertions(+), 145 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 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 b5898f62796e7..2445dea93f5e8 100644 --- a/bundles/org.openhab.binding.hue/README.md +++ b/bundles/org.openhab.binding.hue/README.md @@ -182,8 +182,12 @@ The devices support some of the following channels: | flag | Switch | This channel save flag state for a CLIP sensor. | 0850 | | 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_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 | +| scene | String | This channel activates the scene with the given ID String. The ID String of each scene is assigned by the Hue bridge. | bridge, group | + +To load a hue scene inside a rule for example, the ID of the scene will be required. +You can list all the scene IDs with the following console commands: `hue scenes` and `hue scenes`. ### Trigger Channels @@ -301,6 +305,9 @@ Switch MotionSensorLowBattery { channel="hue:0107:1:motion-sensor:battery_lo // Temperature Sensor Number:Temperature TemperatureSensorTemperature { channel="hue:0302:1: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 +343,9 @@ sitemap demo label="Main Menu" Text item=MotionSensorLastUpdate Text item=MotionSensorBatteryLevel Switch item=MotionSensorLowBattery + + // Light Scenes + Default item=LightScene label="Scene []" } } ``` 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 94822cfe46cf7..bc8284ea6b4df 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 groupState; // 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.groupState = state; } /** @@ -51,7 +62,7 @@ public State getAction() { * * @return lights in the group */ - public List getLights() { + public List getLightIds() { return lights; } 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/HueBindingConstants.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java index 66eb7a84e89b0..c2ccb06e0c818 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 + // Thing 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..b13231dd99953 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,12 +22,14 @@ 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; 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; @@ -114,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. * @@ -851,6 +864,39 @@ 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().stream()// + .map(e -> { + e.getValue().setId(e.getKey()); + return e.getValue(); + })// + .filter(scene -> !scene.isRecycle())// + .sorted(Comparator.comparing(Scene::extractKeyForComparator))// + .collect(Collectors.toList()); + } + + /** + * 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..10e328a7842e3 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; @@ -44,7 +45,9 @@ import org.openhab.binding.hue.internal.handler.sensors.TapSwitchHandler; import org.openhab.binding.hue.internal.handler.sensors.TemperatureHandler; import org.osgi.framework.ServiceRegistration; +import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; /** * {@link HueThingHandlerFactory} is a factory for {@link HueBridgeHandler}s. @@ -59,6 +62,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(), @@ -66,8 +70,15 @@ public class HueThingHandlerFactory extends BaseThingHandlerFactory { LightLevelHandler.SUPPORTED_THING_TYPES.stream(), ClipHandler.SUPPORTED_THING_TYPES.stream(), HueGroupHandler.SUPPORTED_THING_TYPES.stream()).flatMap(i -> i).collect(Collectors.toSet())); + private final HueStateDescriptionOptionProvider stateOptionProvider; + private final Map> discoveryServiceRegs = new HashMap<>(); + @Activate + public HueThingHandlerFactory(final @Reference HueStateDescriptionOptionProvider stateOptionProvider) { + this.stateOptionProvider = stateOptionProvider; + } + @Override public @Nullable Thing createThing(ThingTypeUID thingTypeUID, Configuration configuration, @Nullable ThingUID thingUID, @Nullable ThingUID bridgeUID) { @@ -135,7 +146,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())) { @@ -153,7 +164,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 new file mode 100644 index 0000000000000..a983d3ea7ddff --- /dev/null +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/Scene.java @@ -0,0 +1,160 @@ +/** + * 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.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +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 + */ +@NonNullByDefault +public class Scene { + public static final Type GSON_TYPE = new TypeToken>() { + }.getType(); + + private @NonNullByDefault({}) String id; + private @NonNullByDefault({}) String name; + @SerializedName("lights") + private @NonNullByDefault({}) List lightIds; + @SerializedName("group") + private @Nullable String groupId; + private boolean recycle; + + /** + * Default constructor for GSon. + */ + public Scene() { + super(); + } + + /** + * Test constructor + */ + Scene(String id, String name, @Nullable String groupId, List lightIds, boolean recycle) { + this.id = id; + this.name = name; + this.groupId = groupId; + this.lightIds = lightIds; + this.recycle = recycle; + } + + 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 + */ + 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 + */ + 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 @Nullable 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; + } + + /** + * 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(")"); + } + + 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().stream().allMatch(id -> group.getLightIds().contains(id)); + } else { + return group.getId().contentEquals(getGroupId()); + } + } + + public String extractKeyForComparator() { + return (groupId != null ? groupId : "") + "#" + name; + } + + @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/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/console/HueCommandExtension.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/console/HueCommandExtension.java index f32d13144ca94..89a6cc1985bf8 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/console/HueCommandExtension.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/console/HueCommandExtension.java @@ -24,6 +24,7 @@ import org.eclipse.smarthome.io.console.extensions.AbstractConsoleCommandExtension; import org.eclipse.smarthome.io.console.extensions.ConsoleCommandExtension; import org.openhab.binding.hue.internal.handler.HueBridgeHandler; +import org.openhab.binding.hue.internal.handler.HueGroupHandler; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -39,6 +40,7 @@ public class HueCommandExtension extends AbstractConsoleCommandExtension { private static final String USER_NAME = "username"; + private static final String SCENES = "scenes"; private final ThingRegistry thingRegistry; @@ -60,10 +62,13 @@ public void execute(String[] args, Console console) { } ThingHandler thingHandler = null; HueBridgeHandler bridgeHandler = null; + HueGroupHandler groupHandler = null; if (thing != null) { thingHandler = thing.getHandler(); if (thingHandler instanceof HueBridgeHandler) { bridgeHandler = (HueBridgeHandler) thingHandler; + } else if (thingHandler instanceof HueGroupHandler) { + groupHandler = (HueGroupHandler) thingHandler; } } if (thing == null) { @@ -72,14 +77,26 @@ public void execute(String[] args, Console console) { } else if (thingHandler == null) { console.println("No handler initialized for the thing id '" + args[0] + "'"); printUsage(console); - } else if (bridgeHandler == null) { - console.println("'" + args[0] + "' is not a hue bridge id"); + } else if (bridgeHandler == null && groupHandler == null) { + console.println("'" + args[0] + "' is neither a hue bridge id nor a hue group thing id"); printUsage(console); } else { switch (args[1]) { case USER_NAME: - String userName = bridgeHandler.getUserName(); - console.println("Your user name is " + (userName != null ? userName : "undefined")); + if (bridgeHandler != null) { + String userName = bridgeHandler.getUserName(); + console.println("Your user name is " + (userName != null ? userName : "undefined")); + } else { + console.println("'" + args[0] + "' is not a hue bridge id"); + printUsage(console); + } + break; + case SCENES: + if (bridgeHandler != null) { + bridgeHandler.listScenesForConsole().forEach(console::println); + } else if (groupHandler != null) { + groupHandler.listScenesForConsole().forEach(console::println); + } break; default: printUsage(console); @@ -93,6 +110,9 @@ public void execute(String[] args, Console console) { @Override public List getUsages() { - return Arrays.asList(buildCommandUsage(" " + USER_NAME, "show the user name")); + return Arrays.asList(new String[] { buildCommandUsage(" " + USER_NAME, "show the user name"), + buildCommandUsage(" " + SCENES, "list all the scenes with their id"), + buildCommandUsage(" " + SCENES, "list all the scenes from this group with their id") }); + } } 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 bee0d4e983e1f..fc37380b507db 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 @@ -49,7 +49,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/GroupStatusListener.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/GroupStatusListener.java index 3e36de14468d7..681f67d550af2 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,8 +12,11 @@ */ package org.openhab.binding.hue.internal.handler; +import java.util.List; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.hue.internal.FullGroup; +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. @@ -25,7 +28,7 @@ public interface GroupStatusListener { /** * This method returns the group id of listener - * + * * @return groupId String */ String getGroupId(); @@ -55,4 +58,11 @@ public interface GroupStatusListener { * @param group The added group */ void onGroupAdded(FullGroup group); + + /** + * This method is called whenever the list of available scenes is updated. + * + * @param updatedScenes available scenes + */ + void onScenesUpdated(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 750170c594b95..8bb8466fa94a3 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 @@ -16,6 +16,7 @@ import static org.openhab.binding.hue.internal.HueBindingConstants.*; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -24,9 +25,11 @@ import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -34,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; @@ -41,6 +45,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; @@ -50,6 +55,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; @@ -87,7 +93,10 @@ public class HueBridgeHandler extends ConfigStatusBridgeHandler implements HueCl private static final String DEVICE_TYPE = "EclipseSmartHome"; + private static final long SCENE_POLLING_INTERVAL = TimeUnit.SECONDS.convert(10, TimeUnit.MINUTES); + private final Logger logger = LoggerFactory.getLogger(HueBridgeHandler.class); + private final HueStateDescriptionOptionProvider stateDescriptionOptionProvider; private final Map lastLightStates = new ConcurrentHashMap<>(); private final Map lastSensorStates = new ConcurrentHashMap<>(); @@ -121,7 +130,9 @@ public void run() { } catch (UnauthorizedException | IllegalStateException e) { if (isReachable(hueBridge.getIPAddress())) { lastBridgeConnectionState = false; - onNotAuthenticated(); + if (onNotAuthenticated()) { + updateStatus(ThingStatus.ONLINE); + } } else if (lastBridgeConnectionState || thing.getStatus() == ThingStatus.INITIALIZING) { lastBridgeConnectionState = false; onConnectionLost(); @@ -141,8 +152,6 @@ public void run() { } } - protected abstract void doConnectedRun() throws IOException, ApiException; - private boolean isReachable(String ipAddress) { try { // note that InetAddress.isReachable is unreliable, see @@ -165,6 +174,8 @@ private boolean isReachable(String ipAddress) { } return true; } + + protected abstract void doConnectedRun() throws IOException, ApiException; } private final Runnable sensorPollingRunnable = new PollingRunnable() { @@ -214,6 +225,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; @@ -259,17 +275,23 @@ protected void doConnectedRun() throws IOException, ApiException { discovery.removeLightDiscovery(light); } }); + } + private void updateGroups() throws IOException, ApiException { Map lastGroupStateCopy = new HashMap<>(lastGroupStates); - for (final FullGroup fullGroup : hueBridge.getGroups()) { + List groups = hueBridge.getGroups(); + + final HueLightDiscoveryService discovery = discoveryService; + + for (final FullGroup fullGroup : groups) { State groupState = new State(); boolean on = false; int sumBri = 0; int nbBri = 0; State colorRef = null; HSBType firstColorHsb = null; - for (String lightId : fullGroup.getLights()) { + for (String lightId : fullGroup.getLightIds()) { FullLight light = lastLightStates.get(lightId); if (light != null) { final State lightState = light.getState(); @@ -314,7 +336,7 @@ protected void doConnectedRun() throws IOException, ApiException { final GroupStatusListener groupStatusListener = groupStatusListeners.get(groupId); if (groupStatusListener == null) { logger.trace("Hue group '{}' ({}) added (nb lights {}).", groupId, fullGroup.getName(), - fullGroup.getLights().size()); + fullGroup.getLightIds().size()); if (discovery != null && !lastGroupStateCopy.containsKey(groupId)) { discovery.addGroupDiscovery(fullGroup); @@ -346,23 +368,52 @@ protected void doConnectedRun() throws IOException, ApiException { } }; + private final Runnable scenePollingRunnable = new PollingRunnable() { + @Override + protected void doConnectedRun() throws IOException, ApiException { + 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().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getName())); + List stateOptions = scenes.stream().map(scene -> scene.toStateOption(groupNames)) + .collect(Collectors.toList()); + stateDescriptionOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SCENE), + stateOptions); + consoleScenesList = scenes.stream().map(scene -> "Id is \"" + scene.getId() + "\" for scene \"" + + scene.toStateOption(groupNames).getLabel() + "\"").collect(Collectors.toList()); + } + }; + private boolean lastBridgeConnectionState = false; private boolean propertiesInitializedSuccessfully = false; + private @Nullable Future initJob; 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) { + private List consoleScenesList = new ArrayList<>(); + + public HueBridgeHandler(Bridge bridge, HueStateDescriptionOptionProvider stateDescriptionOptionProvider) { super(bridge); + this.stateDescriptionOptionProvider = stateDescriptionOptionProvider; } @Override public void handleCommand(ChannelUID channelUID, Command command) { - // not needed + if (CHANNEL_SCENE.equals(channelUID.getId()) && command instanceof StringType) { + recallScene(command.toString()); + } } @Override @@ -376,11 +427,11 @@ public void updateLightState(LightStatusListener listener, FullLight light, Stat listener.setPollBypass(fadeTime); } catch (Exception e) { listener.unsetPollBypass(); - handleStateUpdateException(listener, light, stateUpdate, fadeTime, e); + handleLightUpdateException(listener, light, stateUpdate, fadeTime, e); } }).exceptionally(e -> { listener.unsetPollBypass(); - handleStateUpdateException(listener, light, stateUpdate, fadeTime, e); + handleLightUpdateException(listener, light, stateUpdate, fadeTime, e); return null; }); } else { @@ -395,10 +446,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 { @@ -413,10 +464,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 { @@ -434,11 +485,11 @@ public void updateGroupState(FullGroup group, StateUpdate stateUpdate, long fade setGroupPollBypass(group, fadeTime); } catch (Exception e) { unsetGroupPollBypass(group); - handleStateUpdateException(group, stateUpdate, e); + handleGroupUpdateException(group, e); } }).exceptionally(e -> { unsetGroupPollBypass(group); - handleStateUpdateException(group, stateUpdate, e); + handleGroupUpdateException(group, e); return null; }); } else { @@ -447,7 +498,7 @@ public void updateGroupState(FullGroup group, StateUpdate stateUpdate, long fade } private void setGroupPollBypass(FullGroup group, long bypassTime) { - group.getLights().forEach((lightId) -> { + group.getLightIds().forEach((lightId) -> { final LightStatusListener listener = lightStatusListeners.get(lightId); if (listener != null) { listener.setPollBypass(bypassTime); @@ -456,7 +507,7 @@ private void setGroupPollBypass(FullGroup group, long bypassTime) { } private void unsetGroupPollBypass(FullGroup group) { - group.getLights().forEach((lightId) -> { + group.getLightIds().forEach((lightId) -> { final LightStatusListener listener = lightStatusListeners.get(lightId); if (listener != null) { listener.unsetPollBypass(); @@ -464,7 +515,7 @@ private void unsetGroupPollBypass(FullGroup group) { }); } - private void handleStateUpdateException(LightStatusListener listener, FullLight light, StateUpdate stateUpdate, + private void handleLightUpdateException(LightStatusListener listener, FullLight light, StateUpdate stateUpdate, long fadeTime, Throwable e) { if (e instanceof DeviceOffException) { if (stateUpdate.getColorTemperature() != null && stateUpdate.getBrightness() == null) { @@ -475,87 +526,58 @@ private void handleStateUpdateException(LightStatusListener listener, FullLight updateLightState(listener, light, LightStateConverter.toOnOffLightState(OnOffType.ON), fadeTime); updateLightState(listener, light, stateUpdate, fadeTime); } - } 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); final HueLightDiscoveryService discovery = discoveryService; if (discovery != null) { discovery.removeLightDiscovery(light); } - listener.onLightGone(); - } 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); } } - 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) { + private void handleSensorUpdateException(FullSensor sensor, Throwable e) { + if (e instanceof EntityNotAvailableException) { logger.debug("Error while accessing sensor: {}", e.getMessage(), e); final HueLightDiscoveryService discovery = discoveryService; if (discovery != null) { discovery.removeSensorDiscovery(sensor); } - final SensorStatusListener listener = sensorStatusListeners.get(sensor.getId()); if (listener != null) { listener.onSensorGone(); } - } 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()); + } else { + handleThingUpdateException("sensor", e); } } - 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) { + private void handleGroupUpdateException(FullGroup group, Throwable e) { + if (e instanceof EntityNotAvailableException) { logger.debug("Error while accessing group: {}", e.getMessage(), e); final HueLightDiscoveryService discovery = discoveryService; if (discovery != null) { discovery.removeGroupDiscovery(group); } - final GroupStatusListener listener = groupStatusListeners.get(group.getId()); if (listener != null) { listener.onGroupGone(); } - } 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()); + } else { + handleThingUpdateException("group", e); } } - private void handleConfigUpdateException(FullSensor sensor, ConfigUpdate configUpdate, Throwable e) { + private void handleThingUpdateException(String thingType, 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); - final HueLightDiscoveryService discovery = discoveryService; - if (discovery != null) { - discovery.removeSensorDiscovery(sensor); - } - - final SensorStatusListener listener = sensorStatusListeners.get(sensor.getId()); - if (listener != null) { - listener.onSensorGone(); - } } 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()); } } @@ -571,7 +593,8 @@ private void startLightPolling() { } else { lightPollingInterval = configPollingInterval; } - lightPollingJob = scheduler.scheduleWithFixedDelay(lightPollingRunnable, 1, lightPollingInterval, + // Delay the first execution to give a chance to have all light and group things registered + lightPollingJob = scheduler.scheduleWithFixedDelay(lightPollingRunnable, 3, lightPollingInterval, TimeUnit.SECONDS); } } @@ -597,7 +620,8 @@ private void startSensorPolling() { } else { sensorPollingInterval = configSensorPollingInterval; } - sensorPollingJob = scheduler.scheduleWithFixedDelay(sensorPollingRunnable, 1, sensorPollingInterval, + // Delay the first execution to give a chance to have all sensor things registered + sensorPollingJob = scheduler.scheduleWithFixedDelay(sensorPollingRunnable, 4000, sensorPollingInterval, TimeUnit.MILLISECONDS); } } @@ -611,11 +635,33 @@ private void stopSensorPolling() { sensorPollingJob = null; } + private void startScenePolling() { + ScheduledFuture job = scenePollingJob; + if (job == null || job.isCancelled()) { + // Delay the first execution to give a chance to have all group things registered + scenePollingJob = scheduler.scheduleWithFixedDelay(scenePollingRunnable, 5, SCENE_POLLING_INTERVAL, + TimeUnit.SECONDS); + } + } + + private void stopScenePolling() { + ScheduledFuture job = scenePollingJob; + if (job != null) { + job.cancel(true); + } + scenePollingJob = null; + } + @Override public void dispose() { logger.debug("Handler disposed."); + Future job = initJob; + if (job != null) { + job.cancel(true); + } stopLightPolling(); stopSensorPolling(); + stopScenePolling(); if (hueBridge != null) { hueBridge = null; } @@ -634,6 +680,14 @@ public void initialize() { if (hueBridge == null) { hueBridge = new HueBridge(ip, hueBridgeConfig.getPort(), hueBridgeConfig.getProtocol(), scheduler); hueBridge.setTimeout(5000); + + // Try a first connection that will fail, then try to authenticate, + // and finally change the bridge status to ONLINE + initJob = scheduler.submit(new PollingRunnable() { + @Override + protected void doConnectedRun() throws IOException, ApiException { + } + }); } onUpdate(); } @@ -647,6 +701,7 @@ private synchronized void onUpdate() { if (hueBridge != null) { startLightPolling(); startSensorPolling(); + startScenePolling(); } } @@ -867,6 +922,29 @@ public boolean unregisterGroupStatusListener(GroupStatusListener groupStatusList return groupStatusListeners.remove(groupStatusListener.getGroupId()) != null; } + /** + * 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); @@ -935,6 +1013,14 @@ public void startSearch(List serialNumbers) { return null; } + private void notifyGroupSceneUpdate(List scenes) { + groupStatusListeners.forEach((groupId, listener) -> listener.onScenesUpdated(scenes)); + } + + public List listScenesForConsole() { + return consoleScenesList; + } + @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/HueClient.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueClient.java index 4f3496ea08988..92463d1bf2be5 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 @@ -34,7 +34,7 @@ public interface HueClient { /** * Register {@link HueLightDiscoveryService} to bridge handler - * + * * @param listener the discovery service * @return {@code true} if the new discovery service is accepted */ @@ -42,7 +42,7 @@ public interface HueClient { /** * Unregister {@link HueLightDiscoveryService} from bridge handler - * + * * @return {@code true} if the discovery service was removed */ boolean unregisterDiscoveryListener(); @@ -99,7 +99,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); @@ -108,7 +108,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); @@ -117,7 +117,7 @@ public interface HueClient { * Get the group by its ID. * * @param groupId the group ID - * @return the full group representation of {@code null} if it could not be found + * @return the full group representation or {@code null} if it could not be found */ @Nullable FullGroup getGroupById(String groupId); @@ -155,4 +155,11 @@ public interface HueClient { * @param stateUpdate the state update */ void updateGroupState(FullGroup group, StateUpdate stateUpdate, long fadeTime); + + /** + * 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/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 0e17f39d10987..d90a1cc6bc671 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 @@ -15,11 +15,14 @@ import static org.openhab.binding.hue.internal.HueBindingConstants.*; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -38,8 +41,10 @@ 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.Scene; import org.openhab.binding.hue.internal.State; import org.openhab.binding.hue.internal.State.ColorMode; import org.openhab.binding.hue.internal.StateUpdate; @@ -57,6 +62,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; @@ -70,8 +76,11 @@ public class HueGroupHandler extends BaseThingHandler implements GroupStatusList private @Nullable ScheduledFuture scheduledFuture; private @Nullable FullGroup lastFullGroup; - public HueGroupHandler(Thing thing) { + private List consoleScenesList = new ArrayList<>(); + + public HueGroupHandler(Thing thing, HueStateDescriptionOptionProvider stateDescriptionOptionProvider) { super(thing); + this.stateDescriptionOptionProvider = stateDescriptionOptionProvider; } @Override @@ -166,79 +175,79 @@ public void handleCommand(String channel, Command command, long fadeTime) { } Integer lastColorTemp; - 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()); - groupState.setOn(true); - groupState.setTransitionTime(fadeTime); + newState = LightStateConverter.toColorLightState(hsbCommand, group.getState()); + newState.setOn(true); + newState.setTransitionTime(fadeTime); } } else if (command instanceof PercentType) { - groupState = LightStateConverter.toBrightnessLightState((PercentType) command); - 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); - 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); - 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); } } lastColorTemp = lastSentColorTemp; - if (groupState != null && lastColorTemp != null) { + if (newState != null && lastColorTemp != 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(lastColorTemp); - groupState.setTransitionTime(fadeTime); + newState.setColorTemperature(lastColorTemp); + newState.setTransitionTime(fadeTime); } break; case CHANNEL_SWITCH: if (command instanceof OnOffType) { - groupState = LightStateConverter.toOnOffLightState((OnOffType) command); + newState = LightStateConverter.toOnOffLightState((OnOffType) command); } lastColorTemp = lastSentColorTemp; - if (groupState != null && lastColorTemp != null) { + if (newState != null && lastColorTemp != 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(lastColorTemp); - groupState.setTransitionTime(fadeTime); + newState.setColorTemperature(lastColorTemp); + newState.setTransitionTime(fadeTime); } break; case CHANNEL_ALERT: if (command instanceof StringType) { - groupState = LightStateConverter.toAlertState((StringType) command); - if (groupState == null) { + newState = LightStateConverter.toAlertState((StringType) command); + if (newState == null) { // Unsupported StringType is passed. Log a warning // message and return. logger.warn("Unsupported String command: {}. Supported commands are: {}, {}, {} ", command, @@ -250,25 +259,39 @@ public void handleCommand(String channel, Command command, long 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, fadeTime); + if (newState != null) { + cacheNewState(newState); + bridgeHandler.updateGroupState(group, newState, fadeTime); } 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()); @@ -288,25 +311,23 @@ 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) { @@ -397,6 +418,29 @@ public void onGroupGone() { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/offline.group-removed"); } + /** + * Sets the state options for applicable scenes. + */ + @Override + public void onScenesUpdated(List updatedScenes) { + List stateOptions = Collections.emptyList(); + consoleScenesList = new ArrayList<>(); + HueClient handler = getHueClient(); + if (handler != null) { + FullGroup group = handler.getGroupById(groupId); + if (group != null) { + stateOptions = updatedScenes.stream().filter(scene -> scene.isApplicableTo(group)) + .map(Scene::toStateOption).collect(Collectors.toList()); + consoleScenesList = updatedScenes + .stream().filter(scene -> scene.isApplicableTo(group)).map(scene -> "Id is \"" + scene.getId() + + "\" for scene \"" + scene.toStateOption().getLabel() + "\"") + .collect(Collectors.toList()); + } + } + stateDescriptionOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SCENE), + stateOptions); + } + /** * Schedules restoration of the alert item state to {@link LightStateConverter#ALERT_MODE_NONE} after a given time. *
@@ -463,6 +507,10 @@ private int getAlertDuration(Command command) { return delay; } + public List listScenesForConsole() { + return consoleScenesList; + } + @Override public String getGroupId() { return groupId; 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; + } +} 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 35aed6da6a848..dded6ad149ae4 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 @@ -18,6 +18,7 @@ + groupId 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 abc3ec529f03e..4d9c9049db9a7 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..2a48b2e421416 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 @@ -213,4 +213,10 @@ Flag of CLIP sensor. + + + String + + The scene channel allows recalling a scene to all lights that belong to the scene. + 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..702106d926d7c --- /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 testGetScenesExcludeRecycleScenes() 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 testGetScenesOrderByGroup() 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..efd0326e7c8e5 --- /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 testIsApplicableToHasGroupIdMatchingGroup() { + 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 testIsApplicableToHasGroupIdNotMatchingGroup() { + 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 testIsApplicableToNoGroupIdSceneLightsContainedInGroup() { + 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 testIsApplicableToNoGroupIdSceneLightsNotContainedInGroup() { + 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)); + } +} diff --git a/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/handler/HueLightHandlerTest.java b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/handler/HueLightHandlerTest.java index a3b63b8a21b88..c9ffdb32ee05e 100644 --- a/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/handler/HueLightHandlerTest.java +++ b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/handler/HueLightHandlerTest.java @@ -71,25 +71,25 @@ public void setUp() { } @Test - public void assertCommandForOsramPar16_50ForColorTemperatureChannelOn() { + public void assertCommandForOsramPar1650ForColorTemperatureChannelOn() { String expectedReply = "{\"on\" : true, \"bri\" : 254}"; assertSendCommandForColorTempForPar16(OnOffType.ON, new HueLightState(OSRAM_MODEL_TYPE), expectedReply); } @Test - public void assertCommandForOsramPar16_50ForColorTemperatureChannelOff() { + public void assertCommandForOsramPar1650ForColorTemperatureChannelOff() { String expectedReply = "{\"on\" : false, \"transitiontime\" : 0}"; assertSendCommandForColorTempForPar16(OnOffType.OFF, new HueLightState(OSRAM_MODEL_TYPE), expectedReply); } @Test - public void assertCommandForOsramPar16_50ForBrightnessChannelOn() { + public void assertCommandForOsramPar1650ForBrightnessChannelOn() { String expectedReply = "{\"on\" : true, \"bri\" : 254}"; assertSendCommandForBrightnessForPar16(OnOffType.ON, new HueLightState(OSRAM_MODEL_TYPE), expectedReply); } @Test - public void assertCommandForOsramPar16_50ForBrightnessChannelOff() { + public void assertCommandForOsramPar1650ForBrightnessChannelOff() { String expectedReply = "{\"on\" : false, \"transitiontime\" : 0}"; assertSendCommandForBrightnessForPar16(OnOffType.OFF, new HueLightState(OSRAM_MODEL_TYPE), expectedReply); }