]> git.basschouten.com Git - openhab-addons.git/blob
6b966fb246de3ad3dbc659be8ae4340c982e72dc
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.deconz.internal.handler;
14
15 import static org.openhab.binding.deconz.internal.BindingConstants.*;
16 import static org.openhab.binding.deconz.internal.Util.*;
17
18 import java.math.BigDecimal;
19 import java.util.HashMap;
20 import java.util.Map;
21 import java.util.Set;
22 import java.util.stream.Collectors;
23 import java.util.stream.Stream;
24
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;
52
53 import com.google.gson.Gson;
54
55 /**
56  * This light thing doesn't establish any connections, that is done by the bridge Thing.
57  *
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.
60  *
61  * A REST API call is made to get the initial light/rollershutter state.
62  *
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.
66  *
67  * @author Jan N. Klug - Initial contribution
68  */
69 @NonNullByDefault
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());
74
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
78
79     private final Logger logger = LoggerFactory.getLogger(LightThingHandler.class);
80
81     private final StateDescriptionProvider stateDescriptionProvider;
82
83     private long lastCommandExpireTimestamp = 0;
84     private boolean needsPropertyUpdate = false;
85
86     /**
87      * The light state. Contains all possible fields for all supported lights
88      */
89     private LightState lightStateCache = new LightState();
90     private LightState lastCommand = new LightState();
91
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;
95
96     public LightThingHandler(Thing thing, Gson gson, StateDescriptionProvider stateDescriptionProvider) {
97         super(thing, gson);
98
99         this.stateDescriptionProvider = stateDescriptionProvider;
100     }
101
102     @Override
103     public void initialize() {
104         if (thing.getThingTypeUID().equals(THING_TYPE_COLOR_TEMPERATURE_LIGHT)
105                 || thing.getThingTypeUID().equals(THING_TYPE_EXTENDED_COLOR_LIGHT)) {
106             try {
107                 Map<String, String> properties = thing.getProperties();
108                 String ctMaxString = properties.get(PROPERTY_CT_MAX);
109                 ctMax = ctMaxString == null ? ZCL_CT_MAX : Integer.parseInt(ctMaxString);
110                 String ctMinString = properties.get(PROPERTY_CT_MIN);
111                 ctMin = ctMinString == null ? ZCL_CT_MIN : Integer.parseInt(ctMinString);
112
113                 // minimum and maximum are inverted due to mired/kelvin conversion!
114                 StateDescription stateDescription = StateDescriptionFragmentBuilder.create()
115                         .withMinimum(new BigDecimal(miredToKelvin(ctMax)))
116                         .withMaximum(new BigDecimal(miredToKelvin(ctMin))).build().toStateDescription();
117                 if (stateDescription != null) {
118                     stateDescriptionProvider.setDescription(new ChannelUID(thing.getUID(), CHANNEL_COLOR_TEMPERATURE),
119                             stateDescription);
120                 } else {
121                     logger.warn("Failed to create state description in thing {}", thing.getUID());
122                 }
123             } catch (NumberFormatException e) {
124                 needsPropertyUpdate = true;
125             }
126         }
127         super.initialize();
128     }
129
130     @Override
131     protected void registerListener() {
132         WebSocketConnection conn = connection;
133         if (conn != null) {
134             conn.registerLightListener(config.id, this);
135         }
136     }
137
138     @Override
139     protected void unregisterListener() {
140         WebSocketConnection conn = connection;
141         if (conn != null) {
142             conn.unregisterLightListener(config.id);
143         }
144     }
145
146     @Override
147     protected void requestState() {
148         requestState("lights");
149     }
150
151     @Override
152     public void handleCommand(ChannelUID channelUID, Command command) {
153         if (command instanceof RefreshType) {
154             valueUpdated(channelUID.getId(), lightStateCache);
155             return;
156         }
157
158         LightState newLightState = new LightState();
159         Boolean currentOn = lightStateCache.on;
160         Integer currentBri = lightStateCache.bri;
161
162         switch (channelUID.getId()) {
163             case CHANNEL_ALERT:
164                 if (command instanceof OnOffType) {
165                     newLightState.alert = command == OnOffType.ON ? "alert" : "none";
166                 } else {
167                     return;
168                 }
169             case CHANNEL_SWITCH:
170                 if (command instanceof OnOffType) {
171                     newLightState.on = (command == OnOffType.ON);
172                 } else {
173                     return;
174                 }
175                 break;
176             case CHANNEL_BRIGHTNESS:
177             case CHANNEL_COLOR:
178                 if (command instanceof OnOffType) {
179                     newLightState.on = (command == OnOffType.ON);
180                 } else if (command instanceof HSBType) {
181                     HSBType hsbCommand = (HSBType) command;
182
183                     if ("xy".equals(lightStateCache.colormode)) {
184                         PercentType[] xy = hsbCommand.toXY();
185                         if (xy.length < 2) {
186                             logger.warn("Failed to convert {} to xy-values", command);
187                         }
188                         newLightState.xy = new double[] { xy[0].doubleValue() / 100.0, xy[1].doubleValue() / 100.0 };
189                         newLightState.bri = fromPercentType(hsbCommand.getBrightness());
190                     } else {
191                         // default is colormode "hs" (used when colormode "hs" is set or colormode is unknown)
192                         newLightState.bri = fromPercentType(hsbCommand.getBrightness());
193                         newLightState.hue = (int) (hsbCommand.getHue().doubleValue() * HUE_FACTOR);
194                         newLightState.sat = fromPercentType(hsbCommand.getSaturation());
195                     }
196                 } else if (command instanceof PercentType) {
197                     newLightState.bri = fromPercentType((PercentType) command);
198                 } else if (command instanceof DecimalType) {
199                     newLightState.bri = ((DecimalType) command).intValue();
200                 } else {
201                     return;
202                 }
203
204                 // send on/off state together with brightness if not already set or unknown
205                 Integer newBri = newLightState.bri;
206                 if ((newBri != null) && ((currentOn == null) || ((newBri > 0) != currentOn))) {
207                     newLightState.on = (newBri > 0);
208                 }
209
210                 // fix sending bri=0 when light is already off
211                 if (newBri != null && newBri == 0 && currentOn != null && !currentOn) {
212                     return;
213                 }
214
215                 Double transitiontime = config.transitiontime;
216                 if (transitiontime != null) {
217                     // value is in 1/10 seconds
218                     newLightState.transitiontime = (int) Math.round(10 * transitiontime);
219                 }
220                 break;
221             case CHANNEL_COLOR_TEMPERATURE:
222                 if (command instanceof DecimalType) {
223                     int miredValue = kelvinToMired(((DecimalType) command).intValue());
224                     newLightState.ct = constrainToRange(miredValue, ctMin, ctMax);
225
226                     if (currentOn != null && !currentOn) {
227                         // sending new color temperature is only allowed when light is on
228                         newLightState.on = true;
229                     }
230                 } else {
231                     return;
232                 }
233                 break;
234             case CHANNEL_POSITION:
235                 if (command instanceof UpDownType) {
236                     newLightState.on = (command == UpDownType.DOWN);
237                 } else if (command == StopMoveType.STOP) {
238                     if (currentOn != null && currentOn && currentBri != null && currentBri <= 254) {
239                         // going down or currently stop (254 because of rounding error)
240                         newLightState.on = true;
241                     } else if (currentOn != null && !currentOn && currentBri != null && currentBri > 0) {
242                         // going up or currently stopped
243                         newLightState.on = false;
244                     }
245                 } else if (command instanceof PercentType) {
246                     newLightState.bri = fromPercentType((PercentType) command);
247                 } else {
248                     return;
249                 }
250                 break;
251             default:
252                 // no supported command
253                 return;
254         }
255
256         AsyncHttpClient asyncHttpClient = http;
257         if (asyncHttpClient == null) {
258             return;
259         }
260         String url = buildUrl(bridgeConfig.host, bridgeConfig.httpPort, bridgeConfig.apikey, "lights", config.id,
261                 "state");
262
263         if (newLightState.on != null && !newLightState.on) {
264             // if light shall be off, no other commands are allowed, so reset the new light state
265             newLightState.clear();
266             newLightState.on = false;
267         }
268
269         String json = gson.toJson(newLightState);
270         logger.trace("Sending {} to light {} via {}", json, config.id, url);
271
272         asyncHttpClient.put(url, json, bridgeConfig.timeout).thenAccept(v -> {
273             lastCommandExpireTimestamp = System.currentTimeMillis()
274                     + (newLightState.transitiontime != null ? newLightState.transitiontime
275                             : DEFAULT_COMMAND_EXPIRY_TIME);
276             lastCommand = newLightState;
277             logger.trace("Result code={}, body={}", v.getResponseCode(), v.getBody());
278         }).exceptionally(e -> {
279             logger.debug("Sending command {} to channel {} failed:", command, channelUID, e);
280             return null;
281         });
282     }
283
284     @Override
285     protected @Nullable LightMessage parseStateResponse(AsyncHttpClient.Result r) {
286         if (r.getResponseCode() == 403) {
287             return null;
288         } else if (r.getResponseCode() == 200) {
289             LightMessage lightMessage = gson.fromJson(r.getBody(), LightMessage.class);
290             if (lightMessage != null && needsPropertyUpdate) {
291                 // if we did not receive an ctmin/ctmax, then we probably don't need it
292                 needsPropertyUpdate = false;
293
294                 if (lightMessage.ctmin != null && lightMessage.ctmax != null) {
295                     Map<String, String> properties = new HashMap<>(thing.getProperties());
296                     properties.put(PROPERTY_CT_MAX,
297                             Integer.toString(Util.constrainToRange(lightMessage.ctmax, ZCL_CT_MIN, ZCL_CT_MAX)));
298                     properties.put(PROPERTY_CT_MIN,
299                             Integer.toString(Util.constrainToRange(lightMessage.ctmin, ZCL_CT_MIN, ZCL_CT_MAX)));
300                     updateProperties(properties);
301                 }
302             }
303             return lightMessage;
304         } else {
305             throw new IllegalStateException("Unknown status code " + r.getResponseCode() + " for full state request");
306         }
307     }
308
309     @Override
310     protected void processStateResponse(@Nullable LightMessage stateResponse) {
311         if (stateResponse == null) {
312             return;
313         }
314
315         messageReceived(config.id, stateResponse);
316     }
317
318     private void valueUpdated(String channelId, LightState newState) {
319         Integer bri = newState.bri;
320         Boolean on = newState.on;
321
322         switch (channelId) {
323             case CHANNEL_ALERT:
324                 updateState(channelId, "alert".equals(newState.alert) ? OnOffType.ON : OnOffType.OFF);
325                 break;
326             case CHANNEL_SWITCH:
327                 if (on != null) {
328                     updateState(channelId, OnOffType.from(on));
329                 }
330                 break;
331             case CHANNEL_COLOR:
332                 if (on != null && on == false) {
333                     updateState(channelId, OnOffType.OFF);
334                 } else if (bri != null && newState.colormode != null && newState.colormode.equals("xy")) {
335                     final double @Nullable [] xy = newState.xy;
336                     if (xy != null && xy.length == 2) {
337                         HSBType color = HSBType.fromXY((float) xy[0], (float) xy[1]);
338                         updateState(channelId, new HSBType(color.getHue(), color.getSaturation(), toPercentType(bri)));
339                     }
340                 } else if (bri != null && newState.hue != null && newState.sat != null) {
341                     final Integer hue = newState.hue;
342                     final Integer sat = newState.sat;
343                     updateState(channelId,
344                             new HSBType(new DecimalType(hue / HUE_FACTOR), toPercentType(sat), toPercentType(bri)));
345                 }
346                 break;
347             case CHANNEL_BRIGHTNESS:
348                 if (bri != null && on != null && on) {
349                     updateState(channelId, toPercentType(bri));
350                 } else {
351                     updateState(channelId, OnOffType.OFF);
352                 }
353                 break;
354             case CHANNEL_COLOR_TEMPERATURE:
355                 Integer ct = newState.ct;
356                 if (ct != null && ct >= ctMin && ct <= ctMax) {
357                     updateState(channelId, new DecimalType(miredToKelvin(ct)));
358                 }
359                 break;
360             case CHANNEL_POSITION:
361                 if (bri != null) {
362                     updateState(channelId, toPercentType(bri));
363                 }
364             default:
365         }
366     }
367
368     @Override
369     public void messageReceived(String sensorID, DeconzBaseMessage message) {
370         if (message instanceof LightMessage) {
371             LightMessage lightMessage = (LightMessage) message;
372             logger.trace("{} received {}", thing.getUID(), lightMessage);
373             LightState lightState = lightMessage.state;
374             if (lightState != null) {
375                 if (lastCommandExpireTimestamp > System.currentTimeMillis()
376                         && !lightState.equalsIgnoreNull(lastCommand)) {
377                     // skip for SKIP_UPDATE_TIMESPAN after last command if lightState is different from command
378                     logger.trace("Ignoring differing update after last command until {}", lastCommandExpireTimestamp);
379                     return;
380                 }
381                 lightStateCache = lightState;
382                 if (lightState.reachable != null && lightState.reachable) {
383                     updateStatus(ThingStatus.ONLINE);
384                     thing.getChannels().stream().map(c -> c.getUID().getId()).forEach(c -> valueUpdated(c, lightState));
385                 } else {
386                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Not reachable");
387                     thing.getChannels().stream().map(c -> c.getUID()).forEach(c -> updateState(c, UnDefType.UNDEF));
388                 }
389             }
390         }
391     }
392
393     private PercentType toPercentType(int val) {
394         int scaledValue = (int) Math.ceil(val / BRIGHTNESS_FACTOR);
395         if (scaledValue < 0 || scaledValue > 100) {
396             logger.trace("received value {} (converted to {}). Coercing.", val, scaledValue);
397             scaledValue = scaledValue < 0 ? 0 : scaledValue;
398             scaledValue = scaledValue > 100 ? 100 : scaledValue;
399         }
400         logger.debug("val = '{}', scaledValue = '{}'", val, scaledValue);
401         return new PercentType(scaledValue);
402     }
403
404     private int fromPercentType(PercentType val) {
405         return (int) Math.floor(val.doubleValue() * BRIGHTNESS_FACTOR);
406     }
407 }