2 * Copyright (c) 2010-2020 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;
22 import java.util.stream.Collectors;
23 import java.util.stream.Stream;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.openhab.binding.deconz.internal.StateDescriptionProvider;
28 import org.openhab.binding.deconz.internal.Util;
29 import org.openhab.binding.deconz.internal.dto.DeconzBaseMessage;
30 import org.openhab.binding.deconz.internal.dto.LightMessage;
31 import org.openhab.binding.deconz.internal.dto.LightState;
32 import org.openhab.binding.deconz.internal.netutils.AsyncHttpClient;
33 import org.openhab.binding.deconz.internal.netutils.WebSocketConnection;
34 import org.openhab.core.library.types.DecimalType;
35 import org.openhab.core.library.types.HSBType;
36 import org.openhab.core.library.types.OnOffType;
37 import org.openhab.core.library.types.PercentType;
38 import org.openhab.core.library.types.StopMoveType;
39 import org.openhab.core.library.types.UpDownType;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.thing.ThingTypeUID;
45 import org.openhab.core.types.Command;
46 import org.openhab.core.types.RefreshType;
47 import org.openhab.core.types.StateDescription;
48 import org.openhab.core.types.StateDescriptionFragmentBuilder;
49 import org.openhab.core.types.UnDefType;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
53 import com.google.gson.Gson;
56 * This light thing doesn't establish any connections, that is done by the bridge Thing.
58 * It waits for the bridge to come online, grab the websocket connection and bridge configuration
59 * and registers to the websocket connection as a listener.
61 * A REST API call is made to get the initial light/rollershutter state.
63 * Every light and rollershutter is supported by this Thing, because a unified state is kept
64 * in {@link #lightStateCache}. Every field that got received by the REST API for this specific
65 * sensor is published to the framework.
67 * @author Jan N. Klug - Initial contribution
70 public class LightThingHandler extends DeconzBaseThingHandler<LightMessage> {
71 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPE_UIDS = Stream.of(THING_TYPE_COLOR_TEMPERATURE_LIGHT,
72 THING_TYPE_DIMMABLE_LIGHT, THING_TYPE_COLOR_LIGHT, THING_TYPE_EXTENDED_COLOR_LIGHT, THING_TYPE_ONOFF_LIGHT,
73 THING_TYPE_WINDOW_COVERING, THING_TYPE_WARNING_DEVICE).collect(Collectors.toSet());
75 private static final double HUE_FACTOR = 65535 / 360.0;
76 private static final double BRIGHTNESS_FACTOR = 2.54;
77 private static final long DEFAULT_COMMAND_EXPIRY_TIME = 250; // in ms
79 private final Logger logger = LoggerFactory.getLogger(LightThingHandler.class);
81 private final StateDescriptionProvider stateDescriptionProvider;
83 private long lastCommandExpireTimestamp = 0;
84 private boolean needsPropertyUpdate = false;
87 * The light state. Contains all possible fields for all supported lights
89 private LightState lightStateCache = new LightState();
90 private LightState lastCommand = new LightState();
92 // set defaults, we can override them later if we receive better values
93 private int ctMax = ZCL_CT_MAX;
94 private int ctMin = ZCL_CT_MIN;
96 public LightThingHandler(Thing thing, Gson gson, StateDescriptionProvider stateDescriptionProvider) {
99 this.stateDescriptionProvider = stateDescriptionProvider;
103 public void initialize() {
104 if (thing.getThingTypeUID().equals(THING_TYPE_COLOR_TEMPERATURE_LIGHT)
105 || thing.getThingTypeUID().equals(THING_TYPE_EXTENDED_COLOR_LIGHT)) {
107 Map<String, String> properties = thing.getProperties();
108 ctMax = Integer.parseInt(properties.get(PROPERTY_CT_MAX));
109 ctMin = Integer.parseInt(properties.get(PROPERTY_CT_MIN));
111 // minimum and maximum are inverted due to mired/kelvin conversion!
112 StateDescription stateDescription = StateDescriptionFragmentBuilder.create()
113 .withMinimum(new BigDecimal(miredToKelvin(ctMax)))
114 .withMaximum(new BigDecimal(miredToKelvin(ctMin))).build().toStateDescription();
115 if (stateDescription != null) {
116 stateDescriptionProvider.setDescription(new ChannelUID(thing.getUID(), CHANNEL_COLOR_TEMPERATURE),
119 logger.warn("Failed to create state description in thing {}", thing.getUID());
121 } catch (NumberFormatException e) {
122 needsPropertyUpdate = true;
129 protected void registerListener() {
130 WebSocketConnection conn = connection;
132 conn.registerLightListener(config.id, this);
137 protected void unregisterListener() {
138 WebSocketConnection conn = connection;
140 conn.unregisterLightListener(config.id);
145 protected void requestState() {
146 requestState("lights");
150 public void handleCommand(ChannelUID channelUID, Command command) {
151 if (command instanceof RefreshType) {
152 valueUpdated(channelUID.getId(), lightStateCache);
156 LightState newLightState = new LightState();
157 Boolean currentOn = lightStateCache.on;
158 Integer currentBri = lightStateCache.bri;
160 switch (channelUID.getId()) {
162 if (command instanceof OnOffType) {
163 newLightState.alert = command == OnOffType.ON ? "alert" : "none";
168 if (command instanceof OnOffType) {
169 newLightState.on = (command == OnOffType.ON);
174 case CHANNEL_BRIGHTNESS:
176 if (command instanceof OnOffType) {
177 newLightState.on = (command == OnOffType.ON);
178 } else if (command instanceof HSBType) {
179 HSBType hsbCommand = (HSBType) command;
181 if ("xy".equals(lightStateCache.colormode)) {
182 PercentType[] xy = hsbCommand.toXY();
184 logger.warn("Failed to convert {} to xy-values", command);
186 newLightState.xy = new double[] { xy[0].doubleValue() / 100.0, xy[1].doubleValue() / 100.0 };
187 newLightState.bri = fromPercentType(hsbCommand.getBrightness());
189 // default is colormode "hs" (used when colormode "hs" is set or colormode is unknown)
190 newLightState.bri = fromPercentType(hsbCommand.getBrightness());
191 newLightState.hue = (int) (hsbCommand.getHue().doubleValue() * HUE_FACTOR);
192 newLightState.sat = fromPercentType(hsbCommand.getSaturation());
194 } else if (command instanceof PercentType) {
195 newLightState.bri = fromPercentType((PercentType) command);
196 } else if (command instanceof DecimalType) {
197 newLightState.bri = ((DecimalType) command).intValue();
202 // send on/off state together with brightness if not already set or unknown
203 Integer newBri = newLightState.bri;
204 if ((newBri != null) && ((currentOn == null) || ((newBri > 0) != currentOn))) {
205 newLightState.on = (newBri > 0);
208 // fix sending bri=0 when light is already off
209 if (newBri != null && newBri == 0 && currentOn != null && !currentOn) {
213 Double transitiontime = config.transitiontime;
214 if (transitiontime != null) {
215 // value is in 1/10 seconds
216 newLightState.transitiontime = (int) Math.round(10 * transitiontime);
219 case CHANNEL_COLOR_TEMPERATURE:
220 if (command instanceof DecimalType) {
221 int miredValue = kelvinToMired(((DecimalType) command).intValue());
222 newLightState.ct = constrainToRange(miredValue, ctMin, ctMax);
224 if (currentOn != null && !currentOn) {
225 // sending new color temperature is only allowed when light is on
226 newLightState.on = true;
232 case CHANNEL_POSITION:
233 if (command instanceof UpDownType) {
234 newLightState.on = (command == UpDownType.DOWN);
235 } else if (command == StopMoveType.STOP) {
236 if (currentOn != null && currentOn && currentBri != null && currentBri <= 254) {
237 // going down or currently stop (254 because of rounding error)
238 newLightState.on = true;
239 } else if (currentOn != null && !currentOn && currentBri != null && currentBri > 0) {
240 // going up or currently stopped
241 newLightState.on = false;
243 } else if (command instanceof PercentType) {
244 newLightState.bri = fromPercentType((PercentType) command);
250 // no supported command
254 AsyncHttpClient asyncHttpClient = http;
255 if (asyncHttpClient == null) {
258 String url = buildUrl(bridgeConfig.host, bridgeConfig.httpPort, bridgeConfig.apikey, "lights", config.id,
261 if (newLightState.on != null && !newLightState.on) {
262 // if light shall be off, no other commands are allowed, so reset the new light state
263 newLightState.clear();
264 newLightState.on = false;
267 String json = gson.toJson(newLightState);
268 logger.trace("Sending {} to light {} via {}", json, config.id, url);
270 asyncHttpClient.put(url, json, bridgeConfig.timeout).thenAccept(v -> {
271 lastCommandExpireTimestamp = System.currentTimeMillis()
272 + (newLightState.transitiontime != null ? newLightState.transitiontime
273 : DEFAULT_COMMAND_EXPIRY_TIME);
274 lastCommand = newLightState;
275 logger.trace("Result code={}, body={}", v.getResponseCode(), v.getBody());
276 }).exceptionally(e -> {
277 logger.debug("Sending command {} to channel {} failed:", command, channelUID, e);
283 protected @Nullable LightMessage parseStateResponse(AsyncHttpClient.Result r) {
284 if (r.getResponseCode() == 403) {
286 } else if (r.getResponseCode() == 200) {
287 LightMessage lightMessage = gson.fromJson(r.getBody(), LightMessage.class);
288 if (lightMessage != null && needsPropertyUpdate) {
289 // if we did not receive an ctmin/ctmax, then we probably don't need it
290 needsPropertyUpdate = false;
292 if (lightMessage.ctmin != null && lightMessage.ctmax != null) {
293 Map<String, String> properties = new HashMap<>(thing.getProperties());
294 properties.put(PROPERTY_CT_MAX,
295 Integer.toString(Util.constrainToRange(lightMessage.ctmax, ZCL_CT_MIN, ZCL_CT_MAX)));
296 properties.put(PROPERTY_CT_MIN,
297 Integer.toString(Util.constrainToRange(lightMessage.ctmin, ZCL_CT_MIN, ZCL_CT_MAX)));
298 updateProperties(properties);
303 throw new IllegalStateException("Unknown status code " + r.getResponseCode() + " for full state request");
308 protected void processStateResponse(@Nullable LightMessage stateResponse) {
309 if (stateResponse == null) {
313 messageReceived(config.id, stateResponse);
316 private void valueUpdated(String channelId, LightState newState) {
317 Integer bri = newState.bri;
318 Boolean on = newState.on;
322 updateState(channelId, "alert".equals(newState.alert) ? OnOffType.ON : OnOffType.OFF);
326 updateState(channelId, OnOffType.from(on));
330 if (on != null && on == false) {
331 updateState(channelId, OnOffType.OFF);
332 } else if (bri != null && newState.colormode != null && newState.colormode.equals("xy")) {
333 final double @Nullable [] xy = newState.xy;
334 if (xy != null && xy.length == 2) {
335 HSBType color = HSBType.fromXY((float) xy[0], (float) xy[1]);
336 updateState(channelId, new HSBType(color.getHue(), color.getSaturation(), toPercentType(bri)));
338 } else if (bri != null && newState.hue != null && newState.sat != null) {
339 final Integer hue = newState.hue;
340 final Integer sat = newState.sat;
341 updateState(channelId,
342 new HSBType(new DecimalType(hue / HUE_FACTOR), toPercentType(sat), toPercentType(bri)));
345 case CHANNEL_BRIGHTNESS:
346 if (bri != null && on != null && on) {
347 updateState(channelId, toPercentType(bri));
349 updateState(channelId, OnOffType.OFF);
352 case CHANNEL_COLOR_TEMPERATURE:
353 Integer ct = newState.ct;
354 if (ct != null && ct >= ctMin && ct <= ctMax) {
355 updateState(channelId, new DecimalType(miredToKelvin(ct)));
358 case CHANNEL_POSITION:
360 updateState(channelId, toPercentType(bri));
367 public void messageReceived(String sensorID, DeconzBaseMessage message) {
368 if (message instanceof LightMessage) {
369 LightMessage lightMessage = (LightMessage) message;
370 logger.trace("{} received {}", thing.getUID(), lightMessage);
371 LightState lightState = lightMessage.state;
372 if (lightState != null) {
373 if (lastCommandExpireTimestamp > System.currentTimeMillis()
374 && !lightState.equalsIgnoreNull(lastCommand)) {
375 // skip for SKIP_UPDATE_TIMESPAN after last command if lightState is different from command
376 logger.trace("Ignoring differing update after last command until {}", lastCommandExpireTimestamp);
379 lightStateCache = lightState;
380 if (lightState.reachable != null && lightState.reachable) {
381 updateStatus(ThingStatus.ONLINE);
382 thing.getChannels().stream().map(c -> c.getUID().getId()).forEach(c -> valueUpdated(c, lightState));
384 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Not reachable");
385 thing.getChannels().stream().map(c -> c.getUID()).forEach(c -> updateState(c, UnDefType.UNDEF));
391 private PercentType toPercentType(int val) {
392 int scaledValue = (int) Math.ceil(val / BRIGHTNESS_FACTOR);
393 if (scaledValue < 0 || scaledValue > 100) {
394 logger.trace("received value {} (converted to {}). Coercing.", val, scaledValue);
395 scaledValue = scaledValue < 0 ? 0 : scaledValue;
396 scaledValue = scaledValue > 100 ? 100 : scaledValue;
398 logger.debug("val = '{}', scaledValue = '{}'", val, scaledValue);
399 return new PercentType(scaledValue);
402 private int fromPercentType(PercentType val) {
403 return (int) Math.floor(val.doubleValue() * BRIGHTNESS_FACTOR);