[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_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` |
| 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` |
| position | Rollershutter | R/W | Position of the blind | `windowcovering` |
| 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` |
| all_on | Switch | R | All lights in group are 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.
Their state represents the last command send to the group, not necessarily the actual state of the group.
### 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_EFFECT = "effect";
public static final String CHANNEL_EFFECT_SPEED = "effectSpeed";
public static final String CHANNEL_SCENE = "scene";
// channel uids
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)) {
return new SensorThermostatThingHandler(thing, gson);
} else if (GroupThingHandler.SUPPORTED_THING_TYPE_UIDS.contains(thingTypeUID)) {
return new GroupThingHandler(thing, gson);
return new GroupThingHandler(thing, gson, commandDescriptionProvider);
}
return null;

View File

@ -12,7 +12,7 @@
*/
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.Nullable;
@ -27,20 +27,22 @@ import org.openhab.binding.deconz.internal.types.GroupType;
@NonNullByDefault
public class GroupMessage extends DeconzBaseMessage {
public @Nullable GroupAction action;
public String @Nullable [] devicemembership;
public List<String> devicemembership = List.of();
public @Nullable Boolean hidden;
public String @Nullable [] lights;
public String @Nullable [] lightsequence;
public String @Nullable [] multideviceids;
public Scene @Nullable [] scenes;
public List<String> lights = List.of();
public List<String> lightsequence = List.of();
public List<String> multideviceids = List.of();
public List<Scene> scenes = List.of();
public @Nullable GroupState state;
public @Nullable GroupType type;
@Override
public String toString() {
return "GroupMessage{" + "action=" + action + ", devicemembership=" + Arrays.toString(devicemembership)
+ ", hidden=" + hidden + ", lights=" + Arrays.toString(lights) + ", lightsequence="
+ Arrays.toString(lightsequence) + ", multideviceids=" + Arrays.toString(multideviceids) + ", scenes="
+ Arrays.toString(scenes) + ", state=" + state + ", type=" + type + '}';
return "GroupMessage{" + "e='" + e + '\'' + ", r=" + r + ", t='" + t + '\'' + ", id='" + id + '\''
+ ", manufacturername='" + manufacturername + '\'' + ", modelid='" + modelid + '\'' + ", name='" + name
+ '\'' + ", swversion='" + swversion + '\'' + ", ep='" + ep + '\'' + ", lastseen='" + lastseen + '\''
+ ", 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 originalCommand the original openHAB command (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)
*/
protected void sendCommand(Object object, Command originalCommand, ChannelUID channelUID,
protected void sendCommand(@Nullable Object object, Command originalCommand, ChannelUID channelUID,
@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;
if (asyncHttpClient == null) {
return;
}
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);
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
|| t instanceof CompletionException) {
logger.debug("Get full state failed", t);
} else if (t != null) {
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, t.getMessage());
}
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 java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
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.dto.DeconzBaseMessage;
import org.openhab.binding.deconz.internal.dto.GroupAction;
import org.openhab.binding.deconz.internal.dto.GroupMessage;
import org.openhab.binding.deconz.internal.dto.GroupState;
import org.openhab.binding.deconz.internal.types.ResourceType;
import org.openhab.core.library.types.DecimalType;
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.library.types.*;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingTypeUID;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -39,31 +41,25 @@ import org.slf4j.LoggerFactory;
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
* 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
*/
@NonNullByDefault
public class GroupThingHandler extends DeconzBaseThingHandler {
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPE_UIDS = Set.of(THING_TYPE_LIGHTGROUP);
private final Logger logger = LoggerFactory.getLogger(GroupThingHandler.class);
private final CommandDescriptionProvider commandDescriptionProvider;
/**
* The group state.
*/
private Map<String, String> scenes = Map.of();
private GroupState groupStateCache = new GroupState();
public GroupThingHandler(Thing thing, Gson gson) {
public GroupThingHandler(Thing thing, Gson gson, CommandDescriptionProvider commandDescriptionProvider) {
super(thing, gson, ResourceType.GROUPS);
this.commandDescriptionProvider = commandDescriptionProvider;
}
@Override
@ -113,6 +109,17 @@ public class GroupThingHandler extends DeconzBaseThingHandler {
return;
}
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:
return;
}
@ -127,6 +134,16 @@ public class GroupThingHandler extends DeconzBaseThingHandler {
@Override
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);
}

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);
}

View File

@ -198,7 +198,8 @@ public class SensorThermostatThingHandler extends SensorBaseThingHandler {
}
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.withChannel(ChannelBuilder.create(new ChannelUID(thing.getUID(), CHANNEL_WINDOWOPEN), "String")
.withType(new ChannelTypeUID(BINDING_ID, "open")).build());

View File

@ -60,7 +60,7 @@ public class AsyncHttpClient {
* @param timeout A timeout
* @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);
}

View File

@ -14,6 +14,7 @@ package org.openhab.binding.deconz.internal.netutils;
import java.net.URI;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
@ -104,12 +105,12 @@ public class WebSocketConnection {
connectionListener.connectionEstablished();
}
@SuppressWarnings("null, unused")
@SuppressWarnings({ "null", "unused" })
@OnWebSocketMessage
public void onMessage(String 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) {
logger.trace("Received message has unknown resource type. Skipping message.");
return;

View File

@ -16,6 +16,7 @@
<channel typeId="alert" id="alert"/>
<channel typeId="color" id="color"/>
<channel typeId="ct" id="color_temperature"/>
<channel typeId="scene" id="scene"/>
</channels>
<representation-property>uid</representation-property>
@ -67,4 +68,13 @@
<state pattern="%d K" min="15" max="100000" step="100"/>
</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>

View File

@ -17,9 +17,11 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Objects;
import java.util.Optional;
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 {
String json = new String(DeconzTest.class.getResourceAsStream(filename).readAllBytes(), StandardCharsets.UTF_8);
return gson.fromJson(json, clazz);
try (InputStream inputStream = DeconzTest.class.getResourceAsStream(filename)) {
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