2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.deconz.internal.handler;
15 import static org.openhab.binding.deconz.internal.BindingConstants.*;
16 import static org.openhab.binding.deconz.internal.Util.*;
18 import java.math.BigDecimal;
20 import java.util.stream.Collectors;
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.openhab.binding.deconz.internal.CommandDescriptionProvider;
25 import org.openhab.binding.deconz.internal.StateDescriptionProvider;
26 import org.openhab.binding.deconz.internal.Util;
27 import org.openhab.binding.deconz.internal.dto.DeconzBaseMessage;
28 import org.openhab.binding.deconz.internal.dto.LightMessage;
29 import org.openhab.binding.deconz.internal.dto.LightState;
30 import org.openhab.binding.deconz.internal.types.ResourceType;
31 import org.openhab.core.library.types.*;
32 import org.openhab.core.thing.ChannelUID;
33 import org.openhab.core.thing.Thing;
34 import org.openhab.core.thing.ThingStatus;
35 import org.openhab.core.thing.ThingStatusDetail;
36 import org.openhab.core.thing.ThingTypeUID;
37 import org.openhab.core.thing.binding.builder.ChannelBuilder;
38 import org.openhab.core.thing.binding.builder.ThingBuilder;
39 import org.openhab.core.types.*;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
43 import com.google.gson.Gson;
46 * This light thing doesn't establish any connections, that is done by the bridge Thing.
48 * It waits for the bridge to come online, grab the websocket connection and bridge configuration
49 * and registers to the websocket connection as a listener.
51 * A REST API call is made to get the initial light/rollershutter state.
53 * Every light and rollershutter is supported by this Thing, because a unified state is kept
54 * in {@link #lightStateCache}. Every field that got received by the REST API for this specific
55 * sensor is published to the framework.
57 * @author Jan N. Klug - Initial contribution
60 public class LightThingHandler extends DeconzBaseThingHandler {
61 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPE_UIDS = Set.of(THING_TYPE_COLOR_TEMPERATURE_LIGHT,
62 THING_TYPE_DIMMABLE_LIGHT, THING_TYPE_COLOR_LIGHT, THING_TYPE_EXTENDED_COLOR_LIGHT, THING_TYPE_ONOFF_LIGHT,
63 THING_TYPE_WINDOW_COVERING, THING_TYPE_WARNING_DEVICE, THING_TYPE_DOORLOCK);
65 private static final long DEFAULT_COMMAND_EXPIRY_TIME = 250; // in ms
66 private static final int BRIGHTNESS_DIM_STEP = 26; // ~ 10%
68 private final Logger logger = LoggerFactory.getLogger(LightThingHandler.class);
70 private final StateDescriptionProvider stateDescriptionProvider;
71 private final CommandDescriptionProvider commandDescriptionProvider;
73 private long lastCommandExpireTimestamp = 0;
74 private boolean needsPropertyUpdate = false;
77 * The light state. Contains all possible fields for all supported lights
79 private LightState lightStateCache = new LightState();
80 private LightState lastCommand = new LightState();
81 private String colorMode = "";
83 // set defaults, we can override them later if we receive better values
84 private int ctMax = ZCL_CT_MAX;
85 private int ctMin = ZCL_CT_MIN;
87 public LightThingHandler(Thing thing, Gson gson, StateDescriptionProvider stateDescriptionProvider,
88 CommandDescriptionProvider commandDescriptionProvider) {
89 super(thing, gson, ResourceType.LIGHTS);
90 this.stateDescriptionProvider = stateDescriptionProvider;
91 this.commandDescriptionProvider = commandDescriptionProvider;
95 public void initialize() {
96 if (thing.getThingTypeUID().equals(THING_TYPE_COLOR_TEMPERATURE_LIGHT)
97 || thing.getThingTypeUID().equals(THING_TYPE_EXTENDED_COLOR_LIGHT)) {
99 Map<String, String> properties = thing.getProperties();
100 String ctMaxString = properties.get(PROPERTY_CT_MAX);
101 ctMax = ctMaxString == null ? ZCL_CT_MAX : Integer.parseInt(ctMaxString);
102 String ctMinString = properties.get(PROPERTY_CT_MIN);
103 ctMin = ctMinString == null ? ZCL_CT_MIN : Integer.parseInt(ctMinString);
105 // minimum and maximum are inverted due to mired/kelvin conversion!
106 StateDescription stateDescription = StateDescriptionFragmentBuilder.create()
107 .withMinimum(new BigDecimal(miredToKelvin(ctMax)))
108 .withMaximum(new BigDecimal(miredToKelvin(ctMin))).build().toStateDescription();
109 if (stateDescription != null) {
110 stateDescriptionProvider.setDescription(new ChannelUID(thing.getUID(), CHANNEL_COLOR_TEMPERATURE),
113 logger.warn("Failed to create state description in thing {}", thing.getUID());
115 } catch (NumberFormatException e) {
116 needsPropertyUpdate = true;
119 ThingConfig thingConfig = getConfigAs(ThingConfig.class);
120 colorMode = thingConfig.colormode;
126 public void handleCommand(ChannelUID channelUID, Command command) {
127 if (command instanceof RefreshType) {
128 valueUpdated(channelUID.getId(), lightStateCache);
132 LightState newLightState = new LightState();
133 Boolean currentOn = lightStateCache.on;
134 Integer currentBri = lightStateCache.bri;
136 switch (channelUID.getId()) {
138 if (command instanceof StringType) {
139 newLightState.alert = command.toString();
145 if (command instanceof StringType) {
146 // effect command only allowed for lights that are turned on
147 newLightState.on = true;
148 newLightState.effect = command.toString();
153 case CHANNEL_EFFECT_SPEED:
154 if (command instanceof DecimalType) {
155 newLightState.on = true;
156 newLightState.effectSpeed = Util.constrainToRange(((DecimalType) command).intValue(), 0, 10);
163 if (command instanceof OnOffType) {
164 newLightState.on = (command == OnOffType.ON);
169 case CHANNEL_BRIGHTNESS:
171 if (command instanceof OnOffType) {
172 newLightState.on = (command == OnOffType.ON);
173 } else if (command instanceof IncreaseDecreaseType) {
174 // try to get best value for current brightness
175 int oldBri = currentBri != null ? currentBri
176 : (Boolean.TRUE.equals(currentOn) ? BRIGHTNESS_MAX : BRIGHTNESS_MIN);
177 if (command.equals(IncreaseDecreaseType.INCREASE)) {
178 newLightState.bri = Util.constrainToRange(oldBri + BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
181 newLightState.bri = Util.constrainToRange(oldBri - BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
184 } else if (command instanceof HSBType) {
185 HSBType hsbCommand = (HSBType) command;
186 if ("xy".equals(colorMode)) {
187 PercentType[] xy = hsbCommand.toXY();
189 logger.warn("Failed to convert {} to xy-values", command);
191 newLightState.xy = new double[] { xy[0].doubleValue() / 100.0, xy[1].doubleValue() / 100.0 };
192 newLightState.bri = Util.fromPercentType(hsbCommand.getBrightness());
194 // default is colormode "hs" (used when colormode "hs" is set or colormode is unknown)
195 newLightState.bri = Util.fromPercentType(hsbCommand.getBrightness());
196 newLightState.hue = (int) (hsbCommand.getHue().doubleValue() * HUE_FACTOR);
197 newLightState.sat = Util.fromPercentType(hsbCommand.getSaturation());
199 } else if (command instanceof PercentType) {
200 newLightState.bri = Util.fromPercentType((PercentType) command);
201 } else if (command instanceof DecimalType) {
202 newLightState.bri = ((DecimalType) command).intValue();
207 // send on/off state together with brightness if not already set or unknown
208 Integer newBri = newLightState.bri;
209 if (newBri != null) {
210 newLightState.on = (newBri > 0);
213 // fix sending bri=0 when light is already off
214 if (newBri != null && newBri == 0 && currentOn != null && !currentOn) {
218 Double transitiontime = config.transitiontime;
219 if (transitiontime != null) {
220 // value is in 1/10 seconds
221 newLightState.transitiontime = (int) Math.round(10 * transitiontime);
224 case CHANNEL_COLOR_TEMPERATURE:
225 if (command instanceof DecimalType) {
226 int miredValue = kelvinToMired(((DecimalType) command).intValue());
227 newLightState.ct = constrainToRange(miredValue, ctMin, ctMax);
228 newLightState.on = true;
231 case CHANNEL_POSITION:
232 if (command instanceof UpDownType) {
233 newLightState.on = (command == UpDownType.DOWN);
234 } else if (command == StopMoveType.STOP) {
235 if (currentOn != null && currentOn && currentBri != null && currentBri <= BRIGHTNESS_MAX) {
236 // going down or currently stop (254 because of rounding error)
237 newLightState.on = true;
238 } else if (currentOn != null && !currentOn && currentBri != null && currentBri > BRIGHTNESS_MIN) {
239 // going up or currently stopped
240 newLightState.on = false;
242 } else if (command instanceof PercentType) {
243 newLightState.bri = fromPercentType((PercentType) command);
249 // no supported command
253 Boolean newOn = newLightState.on;
254 if (newOn != null && !newOn) {
255 // if light shall be off, no other commands are allowed, so reset the new light state
256 newLightState.clear();
257 newLightState.on = false;
260 sendCommand(newLightState, command, channelUID, () -> {
261 Integer transitionTime = newLightState.transitiontime;
262 lastCommandExpireTimestamp = System.currentTimeMillis()
263 + (transitionTime != null ? transitionTime : DEFAULT_COMMAND_EXPIRY_TIME);
264 lastCommand = newLightState;
269 protected void processStateResponse(DeconzBaseMessage stateResponse) {
270 if (!(stateResponse instanceof LightMessage)) {
274 LightMessage lightMessage = (LightMessage) stateResponse;
276 if (needsPropertyUpdate) {
277 // if we did not receive an ctmin/ctmax, then we probably don't need it
278 needsPropertyUpdate = false;
280 Integer ctmax = lightMessage.ctmax;
281 Integer ctmin = lightMessage.ctmin;
282 if (ctmin != null && ctmax != null) {
283 Map<String, String> properties = new HashMap<>(thing.getProperties());
284 properties.put(PROPERTY_CT_MAX, Integer.toString(Util.constrainToRange(ctmax, ZCL_CT_MIN, ZCL_CT_MAX)));
285 properties.put(PROPERTY_CT_MIN, Integer.toString(Util.constrainToRange(ctmin, ZCL_CT_MIN, ZCL_CT_MAX)));
286 updateProperties(properties);
290 LightState lightState = lightMessage.state;
291 if (lightState != null && lightState.effect != null) {
292 checkAndUpdateEffectChannels(lightMessage);
295 messageReceived(config.id, lightMessage);
298 private enum EffectLightModel {
304 private void checkAndUpdateEffectChannels(LightMessage lightMessage) {
305 EffectLightModel model = EffectLightModel.UNKNOWN;
306 // try to determine which model we have
307 if (lightMessage.manufacturername.equals("_TZE200_s8gkrkxk")) {
308 // the LIDL Melinara string does not report a proper model name
309 model = EffectLightModel.LIDL_MELINARA;
310 } else if (lightMessage.manufacturername.equals("MLI")) {
311 model = EffectLightModel.TINT_MUELLER;
314 "Could not determine effect light type for thing {}, if you feel this is wrong request adding support on GitHub.",
318 ChannelUID effectChannelUID = new ChannelUID(thing.getUID(), CHANNEL_EFFECT);
319 ChannelUID effectSpeedChannelUID = new ChannelUID(thing.getUID(), CHANNEL_EFFECT_SPEED);
321 if (thing.getChannel(CHANNEL_EFFECT) == null) {
322 ThingBuilder thingBuilder = editThing();
323 thingBuilder.withChannel(
324 ChannelBuilder.create(effectChannelUID, "String").withType(CHANNEL_EFFECT_TYPE_UID).build());
325 if (model == EffectLightModel.LIDL_MELINARA) {
326 // additional channels
327 thingBuilder.withChannel(ChannelBuilder.create(effectSpeedChannelUID, "Number")
328 .withType(CHANNEL_EFFECT_SPEED_TYPE_UID).build());
330 updateThing(thingBuilder.build());
335 List<String> options = List.of("none", "steady", "snow", "rainbow", "snake", "tinkle", "fireworks",
336 "flag", "waves", "updown", "vintage", "fading", "collide", "strobe", "sparkles", "carnival",
338 commandDescriptionProvider.setDescription(effectChannelUID,
339 CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
342 options = List.of("none", "colorloop", "sunset", "party", "worklight", "campfire", "romance",
344 commandDescriptionProvider.setDescription(effectChannelUID,
345 CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
348 options = List.of("none", "colorloop");
349 commandDescriptionProvider.setDescription(effectChannelUID,
350 CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
355 private List<CommandOption> toCommandOptionList(List<String> options) {
356 return options.stream().map(c -> new CommandOption(c, c)).collect(Collectors.toList());
359 private void valueUpdated(String channelId, LightState newState) {
360 Integer bri = newState.bri;
361 Integer hue = newState.hue;
362 Integer sat = newState.sat;
363 Boolean on = newState.on;
367 String alert = newState.alert;
369 updateState(channelId, new StringType(alert));
375 updateState(channelId, OnOffType.from(on));
379 if (on != null && on == false) {
380 updateState(channelId, OnOffType.OFF);
381 } else if (bri != null && "xy".equals(newState.colormode)) {
382 final double @Nullable [] xy = newState.xy;
383 if (xy != null && xy.length == 2) {
384 HSBType color = HSBType.fromXY((float) xy[0], (float) xy[1]);
385 updateState(channelId, new HSBType(color.getHue(), color.getSaturation(), toPercentType(bri)));
387 } else if (bri != null && hue != null && sat != null) {
388 updateState(channelId,
389 new HSBType(new DecimalType(hue / HUE_FACTOR), toPercentType(sat), toPercentType(bri)));
392 case CHANNEL_BRIGHTNESS:
393 if (bri != null && on != null && on) {
394 updateState(channelId, toPercentType(bri));
396 updateState(channelId, OnOffType.OFF);
399 case CHANNEL_COLOR_TEMPERATURE:
400 Integer ct = newState.ct;
401 if (ct != null && ct >= ctMin && ct <= ctMax) {
402 updateState(channelId, new DecimalType(miredToKelvin(ct)));
405 case CHANNEL_POSITION:
407 updateState(channelId, toPercentType(bri));
411 String effect = newState.effect;
412 if (effect != null) {
413 updateState(channelId, new StringType(effect));
416 case CHANNEL_EFFECT_SPEED:
417 Integer effectSpeed = newState.effectSpeed;
418 if (effectSpeed != null) {
419 updateState(channelId, new DecimalType(effectSpeed));
427 public void messageReceived(String sensorID, DeconzBaseMessage message) {
428 if (message instanceof LightMessage) {
429 LightMessage lightMessage = (LightMessage) message;
430 logger.trace("{} received {}", thing.getUID(), lightMessage);
431 LightState lightState = lightMessage.state;
432 if (lightState != null) {
433 if (lastCommandExpireTimestamp > System.currentTimeMillis()
434 && !lightState.equalsIgnoreNull(lastCommand)) {
435 // skip for SKIP_UPDATE_TIMESPAN after last command if lightState is different from command
436 logger.trace("Ignoring differing update after last command until {}", lastCommandExpireTimestamp);
439 if (colorMode.isEmpty()) {
440 String cmode = lightState.colormode;
441 if (cmode != null && ("hs".equals(cmode) || "xy".equals(cmode))) {
442 // only set the color mode if it is hs or xy, not ct
446 lightStateCache = lightState;
447 if (Boolean.TRUE.equals(lightState.reachable)) {
448 updateStatus(ThingStatus.ONLINE);
449 thing.getChannels().stream().map(c -> c.getUID().getId()).forEach(c -> valueUpdated(c, lightState));
451 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Not reachable");
452 thing.getChannels().stream().map(c -> c.getUID()).forEach(c -> updateState(c, UnDefType.UNDEF));