[deconz] initial support for scenes (#9345)

* initial support for scenes
* add documentation

Signed-off-by: Jan N. Klug <jan.n.klug@rub.de>
This commit is contained in:
J-N-K 2020-12-13 00:30:43 +01:00 committed by GitHub
parent 12e5e38cb0
commit af3f8774d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 98 additions and 40 deletions

View File

@ -163,7 +163,7 @@ Other devices support
| color | Color | R/W | Color of an multi-color light | `colorlight`, `extendedcolorlight`, `lightgroup`| | color | Color | R/W | Color of an multi-color light | `colorlight`, `extendedcolorlight`, `lightgroup`|
| color_temperature | Number | R/W | Color temperature in Kelvin. The value range is determined by each individual light | `colortemperaturelight`, `extendedcolorlight`, `lightgroup` | | color_temperature | Number | R/W | Color temperature in Kelvin. The value range is determined by each individual light | `colortemperaturelight`, `extendedcolorlight`, `lightgroup` |
| effect | String | R/W | Effect selection. Allowed commands are set dynamically | `colorlight` | | effect | String | R/W | Effect selection. Allowed commands are set dynamically | `colorlight` |
| effectSpeed | Number | R/W | Effect Speed | `colorlight` | | effectSpeed | Number | W | Effect Speed | `colorlight` |
| lock | Switch | R/W | Lock (ON) or unlock (OFF) the doorlock| `doorlock` | | lock | Switch | R/W | Lock (ON) or unlock (OFF) the doorlock| `doorlock` |
| position | Rollershutter | R/W | Position of the blind | `windowcovering` | | position | Rollershutter | R/W | Position of the blind | `windowcovering` |
| heatsetpoint | Number:Temperature | R/W | Target Temperature in °C | `thermostat` | | heatsetpoint | Number:Temperature | R/W | Target Temperature in °C | `thermostat` |
@ -173,11 +173,11 @@ Other devices support
| alert | Switch | R/W | Turn alerts on/off | `warningdevice`, `lightgroup` | | alert | Switch | R/W | Turn alerts on/off | `warningdevice`, `lightgroup` |
| all_on | Switch | R | All lights in group are on | `lightgroup` | | all_on | Switch | R | All lights in group are on | `lightgroup` |
| any_on | Switch | R | Any light in group is on | `lightgroup` | | any_on | Switch | R | Any light in group is on | `lightgroup` |
| scene | String | W | Recall a scene. Allowed commands are set dynamically | `lightgroup` |
**NOTE:** For groups `color` and `color_temperature` are used for sending commands to the group. **NOTE:** For groups `color` and `color_temperature` are used for sending commands to the group.
Their state represents the last command send to the group, not necessarily the actual state of the group. Their state represents the last command send to the group, not necessarily the actual state of the group.
### Trigger Channels ### Trigger Channels
The dimmer switch additionally supports trigger channels. The dimmer switch additionally supports trigger channels.

View File

@ -116,6 +116,7 @@ public class BindingConstants {
public static final String CHANNEL_LOCK = "lock"; public static final String CHANNEL_LOCK = "lock";
public static final String CHANNEL_EFFECT = "effect"; public static final String CHANNEL_EFFECT = "effect";
public static final String CHANNEL_EFFECT_SPEED = "effectSpeed"; public static final String CHANNEL_EFFECT_SPEED = "effectSpeed";
public static final String CHANNEL_SCENE = "scene";
// channel uids // channel uids
public static final ChannelTypeUID CHANNEL_EFFECT_TYPE_UID = new ChannelTypeUID(BINDING_ID, CHANNEL_EFFECT); public static final ChannelTypeUID CHANNEL_EFFECT_TYPE_UID = new ChannelTypeUID(BINDING_ID, CHANNEL_EFFECT);

View File

@ -94,7 +94,7 @@ public class DeconzHandlerFactory extends BaseThingHandlerFactory {
} else if (SensorThermostatThingHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID)) { } else if (SensorThermostatThingHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID)) {
return new SensorThermostatThingHandler(thing, gson); return new SensorThermostatThingHandler(thing, gson);
} else if (GroupThingHandler.SUPPORTED_THING_TYPE_UIDS.contains(thingTypeUID)) { } else if (GroupThingHandler.SUPPORTED_THING_TYPE_UIDS.contains(thingTypeUID)) {
return new GroupThingHandler(thing, gson); return new GroupThingHandler(thing, gson, commandDescriptionProvider);
} }
return null; return null;

View File

@ -12,7 +12,7 @@
*/ */
package org.openhab.binding.deconz.internal.dto; package org.openhab.binding.deconz.internal.dto;
import java.util.Arrays; import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
@ -27,20 +27,22 @@ import org.openhab.binding.deconz.internal.types.GroupType;
@NonNullByDefault @NonNullByDefault
public class GroupMessage extends DeconzBaseMessage { public class GroupMessage extends DeconzBaseMessage {
public @Nullable GroupAction action; public @Nullable GroupAction action;
public String @Nullable [] devicemembership; public List<String> devicemembership = List.of();
public @Nullable Boolean hidden; public @Nullable Boolean hidden;
public String @Nullable [] lights; public List<String> lights = List.of();
public String @Nullable [] lightsequence; public List<String> lightsequence = List.of();
public String @Nullable [] multideviceids; public List<String> multideviceids = List.of();
public Scene @Nullable [] scenes; public List<Scene> scenes = List.of();
public @Nullable GroupState state; public @Nullable GroupState state;
public @Nullable GroupType type; public @Nullable GroupType type;
@Override @Override
public String toString() { public String toString() {
return "GroupMessage{" + "action=" + action + ", devicemembership=" + Arrays.toString(devicemembership) return "GroupMessage{" + "e='" + e + '\'' + ", r=" + r + ", t='" + t + '\'' + ", id='" + id + '\''
+ ", hidden=" + hidden + ", lights=" + Arrays.toString(lights) + ", lightsequence=" + ", manufacturername='" + manufacturername + '\'' + ", modelid='" + modelid + '\'' + ", name='" + name
+ Arrays.toString(lightsequence) + ", multideviceids=" + Arrays.toString(multideviceids) + ", scenes=" + '\'' + ", swversion='" + swversion + '\'' + ", ep='" + ep + '\'' + ", lastseen='" + lastseen + '\''
+ Arrays.toString(scenes) + ", state=" + state + ", type=" + type + '}'; + ", uniqueid='" + uniqueid + '\'' + ", action=" + action + ", devicemembership=" + devicemembership
+ ", hidden=" + hidden + ", lights=" + lights + ", lightsequence=" + lightsequence + ", multideviceids="
+ multideviceids + ", scenes=" + scenes + ", state=" + state + ", type=" + type + '}';
} }
} }

View File

@ -159,23 +159,37 @@ public abstract class DeconzBaseThingHandler extends BaseThingHandler implements
} }
/** /**
* sends a command to the bridge * sends a command to the bridge with the default command URL
* *
* @param object must be serializable and contain the command * @param object must be serializable and contain the command
* @param originalCommand the original openHAB command (used for logging purposes) * @param originalCommand the original openHAB command (used for logging purposes)
* @param channelUID the channel that this command was send to (used for logging purposes) * @param channelUID the channel that this command was send to (used for logging purposes)
* @param acceptProcessing additional processing after the command was successfully send (might be null) * @param acceptProcessing additional processing after the command was successfully send (might be null)
*/ */
protected void sendCommand(Object object, Command originalCommand, ChannelUID channelUID, protected void sendCommand(@Nullable Object object, Command originalCommand, ChannelUID channelUID,
@Nullable Runnable acceptProcessing) { @Nullable Runnable acceptProcessing) {
sendCommand(object, originalCommand, channelUID, resourceType.getCommandUrl(), acceptProcessing);
}
/**
* sends a command to the bridge with a caller-defined command URL
*
* @param object must be serializable and contain the command
* @param originalCommand the original openHAB command (used for logging purposes)
* @param channelUID the channel that this command was send to (used for logging purposes)
* @param commandUrl the command URL
* @param acceptProcessing additional processing after the command was successfully send (might be null)
*/
protected void sendCommand(@Nullable Object object, Command originalCommand, ChannelUID channelUID,
String commandUrl, @Nullable Runnable acceptProcessing) {
AsyncHttpClient asyncHttpClient = http; AsyncHttpClient asyncHttpClient = http;
if (asyncHttpClient == null) { if (asyncHttpClient == null) {
return; return;
} }
String url = buildUrl(bridgeConfig.host, bridgeConfig.httpPort, bridgeConfig.apikey, String url = buildUrl(bridgeConfig.host, bridgeConfig.httpPort, bridgeConfig.apikey,
resourceType.getIdentifier(), config.id, resourceType.getCommandUrl()); resourceType.getIdentifier(), config.id, commandUrl);
String json = gson.toJson(object); String json = object == null ? null : gson.toJson(object);
logger.trace("Sending {} to {} {} via {}", json, resourceType, config.id, url); logger.trace("Sending {} to {} {} via {}", json, resourceType, config.id, url);
asyncHttpClient.put(url, json, bridgeConfig.timeout).thenAccept(v -> { asyncHttpClient.put(url, json, bridgeConfig.timeout).thenAccept(v -> {

View File

@ -178,7 +178,7 @@ public class DeconzBridgeHandler extends BaseBridgeHandler implements WebSocketC
} else if (t instanceof SocketTimeoutException || t instanceof TimeoutException } else if (t instanceof SocketTimeoutException || t instanceof TimeoutException
|| t instanceof CompletionException) { || t instanceof CompletionException) {
logger.debug("Get full state failed", t); logger.debug("Get full state failed", t);
} else if (t != null) { } else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, t.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, t.getMessage());
} }
return Optional.empty(); return Optional.empty();

View File

@ -14,24 +14,26 @@ package org.openhab.binding.deconz.internal.handler;
import static org.openhab.binding.deconz.internal.BindingConstants.*; import static org.openhab.binding.deconz.internal.BindingConstants.*;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.deconz.internal.CommandDescriptionProvider;
import org.openhab.binding.deconz.internal.Util; import org.openhab.binding.deconz.internal.Util;
import org.openhab.binding.deconz.internal.dto.DeconzBaseMessage; import org.openhab.binding.deconz.internal.dto.DeconzBaseMessage;
import org.openhab.binding.deconz.internal.dto.GroupAction; import org.openhab.binding.deconz.internal.dto.GroupAction;
import org.openhab.binding.deconz.internal.dto.GroupMessage; import org.openhab.binding.deconz.internal.dto.GroupMessage;
import org.openhab.binding.deconz.internal.dto.GroupState; import org.openhab.binding.deconz.internal.dto.GroupState;
import org.openhab.binding.deconz.internal.types.ResourceType; import org.openhab.binding.deconz.internal.types.ResourceType;
import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.*;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing; import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.types.Command; import org.openhab.core.types.Command;
import org.openhab.core.types.CommandDescriptionBuilder;
import org.openhab.core.types.CommandOption;
import org.openhab.core.types.RefreshType; import org.openhab.core.types.RefreshType;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -39,31 +41,25 @@ import org.slf4j.LoggerFactory;
import com.google.gson.Gson; import com.google.gson.Gson;
/** /**
* This light thing doesn't establish any connections, that is done by the bridge Thing. * This group thing doesn't establish any connections, that is done by the bridge Thing.
* *
* It waits for the bridge to come online, grab the websocket connection and bridge configuration * It waits for the bridge to come online, grab the websocket connection and bridge configuration
* and registers to the websocket connection as a listener. * and registers to the websocket connection as a listener.
* *
* A REST API call is made to get the initial light/rollershutter state.
*
* Every light and rollershutter is supported by this Thing, because a unified state is kept
* in {@link #groupStateCache}. Every field that got received by the REST API for this specific
* sensor is published to the framework.
*
* @author Jan N. Klug - Initial contribution * @author Jan N. Klug - Initial contribution
*/ */
@NonNullByDefault @NonNullByDefault
public class GroupThingHandler extends DeconzBaseThingHandler { public class GroupThingHandler extends DeconzBaseThingHandler {
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPE_UIDS = Set.of(THING_TYPE_LIGHTGROUP); public static final Set<ThingTypeUID> SUPPORTED_THING_TYPE_UIDS = Set.of(THING_TYPE_LIGHTGROUP);
private final Logger logger = LoggerFactory.getLogger(GroupThingHandler.class); private final Logger logger = LoggerFactory.getLogger(GroupThingHandler.class);
private final CommandDescriptionProvider commandDescriptionProvider;
/** private Map<String, String> scenes = Map.of();
* The group state.
*/
private GroupState groupStateCache = new GroupState(); private GroupState groupStateCache = new GroupState();
public GroupThingHandler(Thing thing, Gson gson) { public GroupThingHandler(Thing thing, Gson gson, CommandDescriptionProvider commandDescriptionProvider) {
super(thing, gson, ResourceType.GROUPS); super(thing, gson, ResourceType.GROUPS);
this.commandDescriptionProvider = commandDescriptionProvider;
} }
@Override @Override
@ -113,6 +109,17 @@ public class GroupThingHandler extends DeconzBaseThingHandler {
return; return;
} }
break; break;
case CHANNEL_SCENE:
if (command instanceof StringType) {
String sceneId = scenes.get(command.toString());
if (sceneId != null) {
sendCommand(null, command, channelUID, "scene/" + sceneId + "/recall", null);
} else {
logger.debug("Ignoring command {} for {}, scene is not found in available scenes: {}", command,
channelUID, scenes);
}
}
return;
default: default:
return; return;
} }
@ -127,6 +134,16 @@ public class GroupThingHandler extends DeconzBaseThingHandler {
@Override @Override
protected void processStateResponse(DeconzBaseMessage stateResponse) { protected void processStateResponse(DeconzBaseMessage stateResponse) {
if (stateResponse instanceof GroupMessage) {
GroupMessage groupMessage = (GroupMessage) stateResponse;
scenes = groupMessage.scenes.stream().collect(Collectors.toMap(scene -> scene.name, scene -> scene.id));
ChannelUID channelUID = new ChannelUID(thing.getUID(), CHANNEL_SCENE);
commandDescriptionProvider.setDescription(channelUID,
CommandDescriptionBuilder.create().withCommandOptions(groupMessage.scenes.stream()
.map(scene -> new CommandOption(scene.name, scene.name)).collect(Collectors.toList()))
.build());
}
messageReceived(config.id, stateResponse); messageReceived(config.id, stateResponse);
} }

View File

@ -282,7 +282,8 @@ public class LightThingHandler extends DeconzBaseThingHandler {
} }
} }
if (lightMessage.state.effect != null) { LightState lightState = lightMessage.state;
if (lightState != null && lightState.effect != null) {
checkAndUpdateEffectChannels(lightMessage); checkAndUpdateEffectChannels(lightMessage);
} }

View File

@ -198,7 +198,8 @@ public class SensorThermostatThingHandler extends SensorBaseThingHandler {
} }
SensorMessage sensorMessage = (SensorMessage) stateResponse; SensorMessage sensorMessage = (SensorMessage) stateResponse;
if (sensorMessage.state.windowopen != null && thing.getChannel(CHANNEL_WINDOWOPEN) == null) { SensorState sensorState = sensorMessage.state;
if (sensorState != null && sensorState.windowopen != null && thing.getChannel(CHANNEL_WINDOWOPEN) == null) {
ThingBuilder thingBuilder = editThing(); ThingBuilder thingBuilder = editThing();
thingBuilder.withChannel(ChannelBuilder.create(new ChannelUID(thing.getUID(), CHANNEL_WINDOWOPEN), "String") thingBuilder.withChannel(ChannelBuilder.create(new ChannelUID(thing.getUID(), CHANNEL_WINDOWOPEN), "String")
.withType(new ChannelTypeUID(BINDING_ID, "open")).build()); .withType(new ChannelTypeUID(BINDING_ID, "open")).build());

View File

@ -60,7 +60,7 @@ public class AsyncHttpClient {
* @param timeout A timeout * @param timeout A timeout
* @return The result * @return The result
*/ */
public CompletableFuture<Result> put(String address, String jsonString, int timeout) { public CompletableFuture<Result> put(String address, @Nullable String jsonString, int timeout) {
return doNetwork(HttpMethod.PUT, address, jsonString, timeout); return doNetwork(HttpMethod.PUT, address, jsonString, timeout);
} }

View File

@ -14,6 +14,7 @@ package org.openhab.binding.deconz.internal.netutils;
import java.net.URI; import java.net.URI;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
@ -104,12 +105,12 @@ public class WebSocketConnection {
connectionListener.connectionEstablished(); connectionListener.connectionEstablished();
} }
@SuppressWarnings("null, unused") @SuppressWarnings({ "null", "unused" })
@OnWebSocketMessage @OnWebSocketMessage
public void onMessage(String message) { public void onMessage(String message) {
logger.trace("Raw data received by websocket {}: {}", socketName, message); logger.trace("Raw data received by websocket {}: {}", socketName, message);
DeconzBaseMessage changedMessage = gson.fromJson(message, DeconzBaseMessage.class); DeconzBaseMessage changedMessage = Objects.requireNonNull(gson.fromJson(message, DeconzBaseMessage.class));
if (changedMessage.r == ResourceType.UNKNOWN) { if (changedMessage.r == ResourceType.UNKNOWN) {
logger.trace("Received message has unknown resource type. Skipping message."); logger.trace("Received message has unknown resource type. Skipping message.");
return; return;

View File

@ -16,6 +16,7 @@
<channel typeId="alert" id="alert"/> <channel typeId="alert" id="alert"/>
<channel typeId="color" id="color"/> <channel typeId="color" id="color"/>
<channel typeId="ct" id="color_temperature"/> <channel typeId="ct" id="color_temperature"/>
<channel typeId="scene" id="scene"/>
</channels> </channels>
<representation-property>uid</representation-property> <representation-property>uid</representation-property>
@ -67,4 +68,13 @@
<state pattern="%d K" min="15" max="100000" step="100"/> <state pattern="%d K" min="15" max="100000" step="100"/>
</channel-type> </channel-type>
<channel-type id="scene">
<item-type>String</item-type>
<label>Recall Scene</label>
<tags>
<tag>Lighting</tag>
</tags>
</channel-type>
</thing:thing-descriptions> </thing:thing-descriptions>

View File

@ -17,9 +17,11 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@ -90,8 +92,17 @@ public class DeconzTest {
} }
public static <T> T getObjectFromJson(String filename, Class<T> clazz, Gson gson) throws IOException { public static <T> T getObjectFromJson(String filename, Class<T> clazz, Gson gson) throws IOException {
String json = new String(DeconzTest.class.getResourceAsStream(filename).readAllBytes(), StandardCharsets.UTF_8); try (InputStream inputStream = DeconzTest.class.getResourceAsStream(filename)) {
return gson.fromJson(json, clazz); if (inputStream == null) {
throw new IOException("inputstream is null");
}
byte[] bytes = inputStream.readAllBytes();
if (bytes == null) {
throw new IOException("Resulting byte-array empty");
}
String json = new String(bytes, StandardCharsets.UTF_8);
return Objects.requireNonNull(gson.fromJson(json, clazz));
}
} }
@Test @Test