2 * Copyright (c) 2010-2023 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;
19 import java.util.HashMap;
20 import java.util.List;
23 import java.util.stream.Collectors;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.openhab.binding.deconz.internal.DeconzDynamicCommandDescriptionProvider;
28 import org.openhab.binding.deconz.internal.DeconzDynamicStateDescriptionProvider;
29 import org.openhab.binding.deconz.internal.Util;
30 import org.openhab.binding.deconz.internal.dto.DeconzBaseMessage;
31 import org.openhab.binding.deconz.internal.dto.LightMessage;
32 import org.openhab.binding.deconz.internal.dto.LightState;
33 import org.openhab.binding.deconz.internal.types.ResourceType;
34 import org.openhab.core.library.types.DecimalType;
35 import org.openhab.core.library.types.HSBType;
36 import org.openhab.core.library.types.IncreaseDecreaseType;
37 import org.openhab.core.library.types.OnOffType;
38 import org.openhab.core.library.types.PercentType;
39 import org.openhab.core.library.types.QuantityType;
40 import org.openhab.core.library.types.StopMoveType;
41 import org.openhab.core.library.types.StringType;
42 import org.openhab.core.library.types.UpDownType;
43 import org.openhab.core.library.unit.Units;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingStatusDetail;
48 import org.openhab.core.thing.ThingTypeUID;
49 import org.openhab.core.thing.type.ChannelKind;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.CommandOption;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.StateDescriptionFragment;
54 import org.openhab.core.types.StateDescriptionFragmentBuilder;
55 import org.openhab.core.types.UnDefType;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
59 import com.google.gson.Gson;
62 * This light thing doesn't establish any connections, that is done by the bridge Thing.
64 * It waits for the bridge to come online, grab the websocket connection and bridge configuration
65 * and registers to the websocket connection as a listener.
67 * A REST API call is made to get the initial light/rollershutter state.
69 * Every light and rollershutter is supported by this Thing, because a unified state is kept
70 * in {@link #lightStateCache}. Every field that got received by the REST API for this specific
71 * sensor is published to the framework.
73 * @author Jan N. Klug - Initial contribution
76 public class LightThingHandler extends DeconzBaseThingHandler {
77 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPE_UIDS = Set.of(THING_TYPE_COLOR_TEMPERATURE_LIGHT,
78 THING_TYPE_DIMMABLE_LIGHT, THING_TYPE_COLOR_LIGHT, THING_TYPE_EXTENDED_COLOR_LIGHT, THING_TYPE_ONOFF_LIGHT,
79 THING_TYPE_WINDOW_COVERING, THING_TYPE_WARNING_DEVICE, THING_TYPE_DOORLOCK);
81 private static final long DEFAULT_COMMAND_EXPIRY_TIME = 250; // in ms
82 private static final int BRIGHTNESS_DIM_STEP = 26; // ~ 10%
84 private final Logger logger = LoggerFactory.getLogger(LightThingHandler.class);
86 private final DeconzDynamicStateDescriptionProvider stateDescriptionProvider;
87 private final DeconzDynamicCommandDescriptionProvider commandDescriptionProvider;
89 private long lastCommandExpireTimestamp = 0;
90 private boolean needsPropertyUpdate = false;
93 * The light state. Contains all possible fields for all supported lights
95 private LightState lightStateCache = new LightState();
96 private LightState lastCommand = new LightState();
98 private Integer onTime = null; // in 0.1s
99 private String colorMode = "";
101 // set defaults, we can override them later if we receive better values
102 private int ctMax = ZCL_CT_MAX;
103 private int ctMin = ZCL_CT_MIN;
105 public LightThingHandler(Thing thing, Gson gson, DeconzDynamicStateDescriptionProvider stateDescriptionProvider,
106 DeconzDynamicCommandDescriptionProvider commandDescriptionProvider) {
107 super(thing, gson, ResourceType.LIGHTS);
108 this.stateDescriptionProvider = stateDescriptionProvider;
109 this.commandDescriptionProvider = commandDescriptionProvider;
113 public void initialize() {
114 if (thing.getThingTypeUID().equals(THING_TYPE_COLOR_TEMPERATURE_LIGHT)
115 || thing.getThingTypeUID().equals(THING_TYPE_EXTENDED_COLOR_LIGHT)) {
117 Map<String, String> properties = thing.getProperties();
118 String ctMaxString = properties.get(PROPERTY_CT_MAX);
119 ctMax = ctMaxString == null ? ZCL_CT_MAX : Integer.parseInt(ctMaxString);
120 String ctMinString = properties.get(PROPERTY_CT_MIN);
121 ctMin = ctMinString == null ? ZCL_CT_MIN : Integer.parseInt(ctMinString);
123 // minimum and maximum are inverted due to mired/kelvin conversion!
124 StateDescriptionFragment stateDescriptionFragment = StateDescriptionFragmentBuilder.create()
125 .withMinimum(new BigDecimal(miredToKelvin(ctMax)))
126 .withMaximum(new BigDecimal(miredToKelvin(ctMin))).build();
127 stateDescriptionProvider.setDescriptionFragment(
128 new ChannelUID(thing.getUID(), CHANNEL_COLOR_TEMPERATURE), stateDescriptionFragment);
129 } catch (NumberFormatException e) {
130 needsPropertyUpdate = true;
133 ThingConfig thingConfig = getConfigAs(ThingConfig.class);
134 colorMode = thingConfig.colormode;
140 public void handleCommand(ChannelUID channelUID, Command command) {
141 if (channelUID.getId().equals(CHANNEL_ONTIME)) {
142 if (command instanceof QuantityType<?>) {
143 QuantityType<?> onTimeSeconds = ((QuantityType<?>) command).toUnit(Units.SECOND);
144 if (onTimeSeconds != null) {
145 onTime = 10 * onTimeSeconds.intValue();
147 logger.warn("Channel '{}' received command '{}', could not be converted to seconds.", channelUID,
154 if (command instanceof RefreshType) {
155 valueUpdated(channelUID.getId(), lightStateCache);
159 LightState newLightState = new LightState();
160 Boolean currentOn = lightStateCache.on;
161 Integer currentBri = lightStateCache.bri;
163 switch (channelUID.getId()) {
165 if (command instanceof StringType) {
166 newLightState.alert = command.toString();
172 if (command instanceof StringType) {
173 // effect command only allowed for lights that are turned on
174 newLightState.on = true;
175 newLightState.effect = command.toString();
180 case CHANNEL_EFFECT_SPEED:
181 if (command instanceof DecimalType) {
182 newLightState.on = true;
183 newLightState.effectSpeed = Util.constrainToRange(((DecimalType) command).intValue(), 0, 10);
190 if (command instanceof OnOffType) {
191 newLightState.on = (command == OnOffType.ON);
196 case CHANNEL_BRIGHTNESS:
198 if (command instanceof OnOffType) {
199 newLightState.on = (command == OnOffType.ON);
200 } else if (command instanceof IncreaseDecreaseType) {
201 // try to get best value for current brightness
202 int oldBri = currentBri != null ? currentBri
203 : (Boolean.TRUE.equals(currentOn) ? BRIGHTNESS_MAX : BRIGHTNESS_MIN);
204 if (command.equals(IncreaseDecreaseType.INCREASE)) {
205 newLightState.bri = Util.constrainToRange(oldBri + BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
208 newLightState.bri = Util.constrainToRange(oldBri - BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
211 } else if (command instanceof HSBType) {
212 HSBType hsbCommand = (HSBType) command;
213 // XY color is the implicit default: Use XY color mode if i) no color mode is set or ii) if the bulb
214 // is in CT mode or iii) already in XY mode. Only if the bulb is in HS mode, use this one.
215 if ("hs".equals(colorMode)) {
216 newLightState.hue = (int) (hsbCommand.getHue().doubleValue() * HUE_FACTOR);
217 newLightState.sat = Util.fromPercentType(hsbCommand.getSaturation());
219 PercentType[] xy = hsbCommand.toXY();
221 logger.warn("Failed to convert {} to xy-values", command);
223 newLightState.xy = new double[] { xy[0].doubleValue() / 100.0, xy[1].doubleValue() / 100.0 };
225 newLightState.bri = Util.fromPercentType(hsbCommand.getBrightness());
226 } else if (command instanceof PercentType) {
227 newLightState.bri = Util.fromPercentType((PercentType) command);
228 } else if (command instanceof DecimalType) {
229 newLightState.bri = ((DecimalType) command).intValue();
234 // send on/off state together with brightness if not already set or unknown
235 Integer newBri = newLightState.bri;
236 if (newBri != null) {
237 newLightState.on = (newBri > 0);
240 // fix sending bri=0 when light is already off
241 if (newBri != null && newBri == 0 && currentOn != null && !currentOn) {
245 Double transitiontime = config.transitiontime;
246 if (transitiontime != null) {
247 // value is in 1/10 seconds
248 newLightState.transitiontime = (int) Math.round(10 * transitiontime);
251 case CHANNEL_COLOR_TEMPERATURE:
252 if (command instanceof DecimalType) {
253 int miredValue = kelvinToMired(((DecimalType) command).intValue());
254 newLightState.ct = constrainToRange(miredValue, ctMin, ctMax);
255 newLightState.on = true;
258 case CHANNEL_POSITION:
259 if (command instanceof UpDownType) {
260 newLightState.on = (command == UpDownType.DOWN);
261 } else if (command == StopMoveType.STOP) {
262 if (currentOn != null && currentOn && currentBri != null && currentBri <= BRIGHTNESS_MAX) {
263 // going down or currently stop (254 because of rounding error)
264 newLightState.on = true;
265 } else if (currentOn != null && !currentOn && currentBri != null && currentBri > BRIGHTNESS_MIN) {
266 // going up or currently stopped
267 newLightState.on = false;
269 } else if (command instanceof PercentType) {
270 newLightState.bri = fromPercentType((PercentType) command);
276 // no supported command
280 Boolean newOn = newLightState.on;
281 if (newOn != null && !newOn) {
282 // if light shall be off, no other commands are allowed, so reset the new light state
283 newLightState.clear();
284 newLightState.on = false;
285 } else if (newOn != null && newOn) {
286 newLightState.ontime = onTime;
289 sendCommand(newLightState, command, channelUID, () -> {
290 Integer transitionTime = newLightState.transitiontime;
291 lastCommandExpireTimestamp = System.currentTimeMillis()
292 + (transitionTime != null ? transitionTime : DEFAULT_COMMAND_EXPIRY_TIME);
293 lastCommand = newLightState;
298 protected void processStateResponse(DeconzBaseMessage stateResponse) {
299 if (!(stateResponse instanceof LightMessage)) {
303 LightMessage lightMessage = (LightMessage) stateResponse;
305 if (needsPropertyUpdate) {
306 // if we did not receive a ctmin/ctmax, then we probably don't need it
307 needsPropertyUpdate = false;
309 Integer ctmax = lightMessage.ctmax;
310 Integer ctmin = lightMessage.ctmin;
311 if (ctmin != null && ctmax != null) {
312 Map<String, String> properties = new HashMap<>(thing.getProperties());
313 properties.put(PROPERTY_CT_MAX, Integer.toString(Util.constrainToRange(ctmax, ZCL_CT_MIN, ZCL_CT_MAX)));
314 properties.put(PROPERTY_CT_MIN, Integer.toString(Util.constrainToRange(ctmin, ZCL_CT_MIN, ZCL_CT_MAX)));
315 updateProperties(properties);
319 LightState lightState = lightMessage.state;
320 if (lightState != null && lightState.effect != null) {
321 checkAndUpdateEffectChannels(lightMessage);
324 messageReceived(config.id, lightMessage);
327 private enum EffectLightModel {
333 private void checkAndUpdateEffectChannels(LightMessage lightMessage) {
334 EffectLightModel model = EffectLightModel.UNKNOWN;
335 // try to determine which model we have
336 if (lightMessage.manufacturername.equals("_TZE200_s8gkrkxk")) {
337 // the LIDL Melinara string does not report a proper model name
338 model = EffectLightModel.LIDL_MELINARA;
339 } else if (lightMessage.manufacturername.equals("MLI")) {
340 model = EffectLightModel.TINT_MUELLER;
343 "Could not determine effect light type for thing {}, if you feel this is wrong request adding support on GitHub.",
347 ChannelUID effectChannelUID = new ChannelUID(thing.getUID(), CHANNEL_EFFECT);
348 createChannel(CHANNEL_EFFECT, ChannelKind.STATE);
352 // additional channels
353 createChannel(CHANNEL_EFFECT_SPEED, ChannelKind.STATE);
355 List<String> options = List.of("none", "steady", "snow", "rainbow", "snake", "tinkle", "fireworks",
356 "flag", "waves", "updown", "vintage", "fading", "collide", "strobe", "sparkles", "carnival",
358 commandDescriptionProvider.setCommandOptions(effectChannelUID, toCommandOptionList(options));
361 options = List.of("none", "colorloop", "sunset", "party", "worklight", "campfire", "romance",
363 commandDescriptionProvider.setCommandOptions(effectChannelUID, toCommandOptionList(options));
366 options = List.of("none", "colorloop");
367 commandDescriptionProvider.setCommandOptions(effectChannelUID, toCommandOptionList(options));
371 private List<CommandOption> toCommandOptionList(List<String> options) {
372 return options.stream().map(c -> new CommandOption(c, c)).collect(Collectors.toList());
375 private void valueUpdated(String channelId, LightState newState) {
376 Integer bri = newState.bri;
377 Integer hue = newState.hue;
378 Integer sat = newState.sat;
379 Boolean on = newState.on;
383 String alert = newState.alert;
385 updateState(channelId, new StringType(alert));
391 updateState(channelId, OnOffType.from(on));
395 if (on != null && !on) {
396 updateState(channelId, OnOffType.OFF);
397 } else if (bri != null && "xy".equals(newState.colormode)) {
398 final double @Nullable [] xy = newState.xy;
399 if (xy != null && xy.length == 2) {
400 HSBType color = HSBType.fromXY((float) xy[0], (float) xy[1]);
401 updateState(channelId, new HSBType(color.getHue(), color.getSaturation(), toPercentType(bri)));
403 } else if (bri != null && hue != null && sat != null) {
404 updateState(channelId,
405 new HSBType(new DecimalType(hue / HUE_FACTOR), toPercentType(sat), toPercentType(bri)));
408 case CHANNEL_BRIGHTNESS:
409 if (bri != null && on != null && on) {
410 updateState(channelId, toPercentType(bri));
412 updateState(channelId, OnOffType.OFF);
415 case CHANNEL_COLOR_TEMPERATURE:
416 Integer ct = newState.ct;
417 if (ct != null && ct >= ctMin && ct <= ctMax) {
418 updateState(channelId, new DecimalType(miredToKelvin(ct)));
421 case CHANNEL_POSITION:
423 updateState(channelId, toPercentType(bri));
427 String effect = newState.effect;
428 if (effect != null) {
429 updateState(channelId, new StringType(effect));
432 case CHANNEL_EFFECT_SPEED:
433 Integer effectSpeed = newState.effectSpeed;
434 if (effectSpeed != null) {
435 updateState(channelId, new DecimalType(effectSpeed));
443 public void messageReceived(String sensorID, DeconzBaseMessage message) {
444 if (message instanceof LightMessage) {
445 LightMessage lightMessage = (LightMessage) message;
446 logger.trace("{} received {}", thing.getUID(), lightMessage);
447 LightState lightState = lightMessage.state;
448 if (lightState != null) {
449 if (lastCommandExpireTimestamp > System.currentTimeMillis()
450 && !lightState.equalsIgnoreNull(lastCommand)) {
451 // skip for SKIP_UPDATE_TIMESPAN after last command if lightState is different from command
452 logger.trace("Ignoring differing update after last command until {}", lastCommandExpireTimestamp);
455 if (colorMode.isEmpty()) {
456 String cmode = lightState.colormode;
457 if (cmode != null && ("hs".equals(cmode) || "xy".equals(cmode))) {
458 // only set the color mode if it is hs or xy, not ct
462 lightStateCache = lightState;
463 if (Boolean.TRUE.equals(lightState.reachable)) {
464 updateStatus(ThingStatus.ONLINE);
465 thing.getChannels().stream().map(c -> c.getUID().getId()).forEach(c -> valueUpdated(c, lightState));
467 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.light-not-reachable");
468 thing.getChannels().stream().map(c -> c.getUID()).forEach(c -> updateState(c, UnDefType.UNDEF));