]> git.basschouten.com Git - openhab-addons.git/blob
19ad0f71ba48628f660ba79d9d8bd8fa2875a36d
[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.*;
20 import java.util.stream.Collectors;
21
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.netutils.AsyncHttpClient;
31 import org.openhab.binding.deconz.internal.types.ResourceType;
32 import org.openhab.core.library.types.*;
33 import org.openhab.core.thing.ChannelUID;
34 import org.openhab.core.thing.Thing;
35 import org.openhab.core.thing.ThingStatus;
36 import org.openhab.core.thing.ThingStatusDetail;
37 import org.openhab.core.thing.ThingTypeUID;
38 import org.openhab.core.thing.binding.builder.ChannelBuilder;
39 import org.openhab.core.thing.binding.builder.ThingBuilder;
40 import org.openhab.core.types.*;
41 import org.slf4j.Logger;
42 import org.slf4j.LoggerFactory;
43
44 import com.google.gson.Gson;
45
46 /**
47  * This light thing doesn't establish any connections, that is done by the bridge Thing.
48  *
49  * It waits for the bridge to come online, grab the websocket connection and bridge configuration
50  * and registers to the websocket connection as a listener.
51  *
52  * A REST API call is made to get the initial light/rollershutter state.
53  *
54  * Every light and rollershutter is supported by this Thing, because a unified state is kept
55  * in {@link #lightStateCache}. Every field that got received by the REST API for this specific
56  * sensor is published to the framework.
57  *
58  * @author Jan N. Klug - Initial contribution
59  */
60 @NonNullByDefault
61 public class LightThingHandler extends DeconzBaseThingHandler<LightMessage> {
62     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPE_UIDS = Set.of(THING_TYPE_COLOR_TEMPERATURE_LIGHT,
63             THING_TYPE_DIMMABLE_LIGHT, THING_TYPE_COLOR_LIGHT, THING_TYPE_EXTENDED_COLOR_LIGHT, THING_TYPE_ONOFF_LIGHT,
64             THING_TYPE_WINDOW_COVERING, THING_TYPE_WARNING_DEVICE, THING_TYPE_DOORLOCK);
65
66     private static final long DEFAULT_COMMAND_EXPIRY_TIME = 250; // in ms
67     private static final int BRIGHTNESS_DIM_STEP = 26; // ~ 10%
68
69     private final Logger logger = LoggerFactory.getLogger(LightThingHandler.class);
70
71     private final StateDescriptionProvider stateDescriptionProvider;
72     private final CommandDescriptionProvider commandDescriptionProvider;
73
74     private long lastCommandExpireTimestamp = 0;
75     private boolean needsPropertyUpdate = false;
76
77     /**
78      * The light state. Contains all possible fields for all supported lights
79      */
80     private LightState lightStateCache = new LightState();
81     private LightState lastCommand = new LightState();
82
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;
86
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;
92     }
93
94     @Override
95     public void initialize() {
96         if (thing.getThingTypeUID().equals(THING_TYPE_COLOR_TEMPERATURE_LIGHT)
97                 || thing.getThingTypeUID().equals(THING_TYPE_EXTENDED_COLOR_LIGHT)) {
98             try {
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);
104
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),
111                             stateDescription);
112                 } else {
113                     logger.warn("Failed to create state description in thing {}", thing.getUID());
114                 }
115             } catch (NumberFormatException e) {
116                 needsPropertyUpdate = true;
117             }
118         }
119         super.initialize();
120     }
121
122     @Override
123     public void handleCommand(ChannelUID channelUID, Command command) {
124         if (command instanceof RefreshType) {
125             valueUpdated(channelUID.getId(), lightStateCache);
126             return;
127         }
128
129         LightState newLightState = new LightState();
130         Boolean currentOn = lightStateCache.on;
131         Integer currentBri = lightStateCache.bri;
132
133         switch (channelUID.getId()) {
134             case CHANNEL_ALERT:
135                 if (command instanceof OnOffType) {
136                     newLightState.alert = command == OnOffType.ON ? "alert" : "none";
137                 } else {
138                     return;
139                 }
140                 break;
141             case CHANNEL_EFFECT:
142                 if (command instanceof StringType) {
143                     // effect command only allowed for lights that are turned on
144                     newLightState.on = true;
145                     newLightState.effect = command.toString();
146                 } else {
147                     return;
148                 }
149                 break;
150             case CHANNEL_EFFECT_SPEED:
151                 if (command instanceof DecimalType) {
152                     newLightState.on = true;
153                     newLightState.effectSpeed = Util.constrainToRange(((DecimalType) command).intValue(), 0, 10);
154                 } else {
155                     return;
156                 }
157                 break;
158             case CHANNEL_SWITCH:
159             case CHANNEL_LOCK:
160                 if (command instanceof OnOffType) {
161                     newLightState.on = (command == OnOffType.ON);
162                 } else {
163                     return;
164                 }
165                 break;
166             case CHANNEL_BRIGHTNESS:
167             case CHANNEL_COLOR:
168                 if (command instanceof OnOffType) {
169                     newLightState.on = (command == OnOffType.ON);
170                 } else if (command instanceof IncreaseDecreaseType) {
171                     // try to get best value for current brightness
172                     int oldBri = currentBri != null ? currentBri
173                             : (Boolean.TRUE.equals(currentOn) ? BRIGHTNESS_MAX : BRIGHTNESS_MIN);
174                     if (command.equals(IncreaseDecreaseType.INCREASE)) {
175                         newLightState.bri = Util.constrainToRange(oldBri + BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
176                                 BRIGHTNESS_MAX);
177                     } else {
178                         newLightState.bri = Util.constrainToRange(oldBri - BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
179                                 BRIGHTNESS_MAX);
180                     }
181                 } else if (command instanceof HSBType) {
182                     HSBType hsbCommand = (HSBType) command;
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 = Util.fromPercentType(hsbCommand.getBrightness());
190                     } else {
191                         // default is colormode "hs" (used when colormode "hs" is set or colormode is unknown)
192                         newLightState.bri = Util.fromPercentType(hsbCommand.getBrightness());
193                         newLightState.hue = (int) (hsbCommand.getHue().doubleValue() * HUE_FACTOR);
194                         newLightState.sat = Util.fromPercentType(hsbCommand.getSaturation());
195                     }
196                 } else if (command instanceof PercentType) {
197                     newLightState.bri = Util.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) {
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                     newLightState.on = true;
226                 }
227                 break;
228             case CHANNEL_POSITION:
229                 if (command instanceof UpDownType) {
230                     newLightState.on = (command == UpDownType.DOWN);
231                 } else if (command == StopMoveType.STOP) {
232                     if (currentOn != null && currentOn && currentBri != null && currentBri <= BRIGHTNESS_MAX) {
233                         // going down or currently stop (254 because of rounding error)
234                         newLightState.on = true;
235                     } else if (currentOn != null && !currentOn && currentBri != null && currentBri > BRIGHTNESS_MIN) {
236                         // going up or currently stopped
237                         newLightState.on = false;
238                     }
239                 } else if (command instanceof PercentType) {
240                     newLightState.bri = fromPercentType((PercentType) command);
241                 } else {
242                     return;
243                 }
244                 break;
245             default:
246                 // no supported command
247                 return;
248         }
249
250         Boolean newOn = newLightState.on;
251         if (newOn != null && !newOn) {
252             // if light shall be off, no other commands are allowed, so reset the new light state
253             newLightState.clear();
254             newLightState.on = false;
255         }
256
257         sendCommand(newLightState, command, channelUID, () -> {
258             Integer transitionTime = newLightState.transitiontime;
259             lastCommandExpireTimestamp = System.currentTimeMillis()
260                     + (transitionTime != null ? transitionTime : DEFAULT_COMMAND_EXPIRY_TIME);
261             lastCommand = newLightState;
262         });
263     }
264
265     @Override
266     protected @Nullable LightMessage parseStateResponse(AsyncHttpClient.Result r) {
267         if (r.getResponseCode() == 403) {
268             return null;
269         } else if (r.getResponseCode() == 200) {
270             LightMessage lightMessage = Objects.requireNonNull(gson.fromJson(r.getBody(), LightMessage.class));
271             if (needsPropertyUpdate) {
272                 // if we did not receive an ctmin/ctmax, then we probably don't need it
273                 needsPropertyUpdate = false;
274
275                 Integer ctmax = lightMessage.ctmax;
276                 Integer ctmin = lightMessage.ctmin;
277                 if (ctmin != null && ctmax != null) {
278                     Map<String, String> properties = new HashMap<>(thing.getProperties());
279                     properties.put(PROPERTY_CT_MAX,
280                             Integer.toString(Util.constrainToRange(ctmax, ZCL_CT_MIN, ZCL_CT_MAX)));
281                     properties.put(PROPERTY_CT_MIN,
282                             Integer.toString(Util.constrainToRange(ctmin, ZCL_CT_MIN, ZCL_CT_MAX)));
283                     updateProperties(properties);
284                 }
285             }
286             return lightMessage;
287         } else {
288             throw new IllegalStateException("Unknown status code " + r.getResponseCode() + " for full state request");
289         }
290     }
291
292     @Override
293     protected void processStateResponse(@Nullable LightMessage stateResponse) {
294         if (stateResponse == null) {
295             return;
296         }
297         if (stateResponse.state.effect != null) {
298             checkAndUpdateEffectChannels(stateResponse);
299         }
300         messageReceived(config.id, stateResponse);
301     }
302
303     private enum EffectLightModel {
304         LIDL_MELINARA,
305         TINT_MUELLER,
306         UNKNOWN;
307     }
308
309     private void checkAndUpdateEffectChannels(LightMessage lightMessage) {
310         EffectLightModel model = EffectLightModel.UNKNOWN;
311         // try to determine which model we have
312         if (lightMessage.manufacturername.equals("_TZE200_s8gkrkxk")) {
313             // the LIDL Melinara string does not report a proper model name
314             model = EffectLightModel.LIDL_MELINARA;
315         } else if (lightMessage.manufacturername.equals("MLI")) {
316             model = EffectLightModel.TINT_MUELLER;
317         } else {
318             logger.info("Could not determine effect light type for thing {}, please request adding support on GitHub.",
319                     thing.getUID());
320         }
321
322         ChannelUID effectChannelUID = new ChannelUID(thing.getUID(), CHANNEL_EFFECT);
323         ChannelUID effectSpeedChannelUID = new ChannelUID(thing.getUID(), CHANNEL_EFFECT_SPEED);
324
325         if (thing.getChannel(CHANNEL_EFFECT) == null) {
326             ThingBuilder thingBuilder = editThing();
327             thingBuilder.withChannel(
328                     ChannelBuilder.create(effectChannelUID, "String").withType(CHANNEL_EFFECT_TYPE_UID).build());
329             if (model == EffectLightModel.LIDL_MELINARA) {
330                 // additional channels
331                 thingBuilder.withChannel(ChannelBuilder.create(effectSpeedChannelUID, "Number")
332                         .withType(CHANNEL_EFFECT_SPEED_TYPE_UID).build());
333             }
334             updateThing(thingBuilder.build());
335         }
336
337         switch (model) {
338             case LIDL_MELINARA:
339                 List<String> options = List.of("none", "steady", "snow", "rainbow", "snake", "tinkle", "fireworks",
340                         "flag", "waves", "updown", "vintage", "fading", "collide", "strobe", "sparkles", "carnival",
341                         "glow");
342                 commandDescriptionProvider.setDescription(effectChannelUID,
343                         CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
344                 break;
345             case TINT_MUELLER:
346                 options = List.of("none", "colorloop", "sunset", "party", "worklight", "campfire", "romance",
347                         "nightlight");
348                 commandDescriptionProvider.setDescription(effectChannelUID,
349                         CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
350                 break;
351             default:
352                 options = List.of("none", "colorloop");
353                 commandDescriptionProvider.setDescription(effectChannelUID,
354                         CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
355
356         }
357     }
358
359     private List<CommandOption> toCommandOptionList(List<String> options) {
360         return options.stream().map(c -> new CommandOption(c, c)).collect(Collectors.toList());
361     }
362
363     private void valueUpdated(String channelId, LightState newState) {
364         Integer bri = newState.bri;
365         Integer hue = newState.hue;
366         Integer sat = newState.sat;
367         Boolean on = newState.on;
368
369         switch (channelId) {
370             case CHANNEL_ALERT:
371                 updateState(channelId, "alert".equals(newState.alert) ? OnOffType.ON : OnOffType.OFF);
372                 break;
373             case CHANNEL_SWITCH:
374             case CHANNEL_LOCK:
375                 if (on != null) {
376                     updateState(channelId, OnOffType.from(on));
377                 }
378                 break;
379             case CHANNEL_COLOR:
380                 if (on != null && on == false) {
381                     updateState(channelId, OnOffType.OFF);
382                 } else if (bri != null && "xy".equals(newState.colormode)) {
383                     final double @Nullable [] xy = newState.xy;
384                     if (xy != null && xy.length == 2) {
385                         HSBType color = HSBType.fromXY((float) xy[0], (float) xy[1]);
386                         updateState(channelId, new HSBType(color.getHue(), color.getSaturation(), toPercentType(bri)));
387                     }
388                 } else if (bri != null && hue != null && sat != null) {
389                     updateState(channelId,
390                             new HSBType(new DecimalType(hue / HUE_FACTOR), toPercentType(sat), toPercentType(bri)));
391                 }
392                 break;
393             case CHANNEL_BRIGHTNESS:
394                 if (bri != null && on != null && on) {
395                     updateState(channelId, toPercentType(bri));
396                 } else {
397                     updateState(channelId, OnOffType.OFF);
398                 }
399                 break;
400             case CHANNEL_COLOR_TEMPERATURE:
401                 Integer ct = newState.ct;
402                 if (ct != null && ct >= ctMin && ct <= ctMax) {
403                     updateState(channelId, new DecimalType(miredToKelvin(ct)));
404                 }
405                 break;
406             case CHANNEL_POSITION:
407                 if (bri != null) {
408                     updateState(channelId, toPercentType(bri));
409                 }
410                 break;
411             case CHANNEL_EFFECT:
412                 String effect = newState.effect;
413                 if (effect != null) {
414                     updateState(channelId, new StringType(effect));
415                 }
416                 break;
417             case CHANNEL_EFFECT_SPEED:
418                 Integer effectSpeed = newState.effectSpeed;
419                 if (effectSpeed != null) {
420                     updateState(channelId, new DecimalType(effectSpeed));
421                 }
422                 break;
423             default:
424         }
425     }
426
427     @Override
428     public void messageReceived(String sensorID, DeconzBaseMessage message) {
429         if (message instanceof LightMessage) {
430             LightMessage lightMessage = (LightMessage) message;
431             logger.trace("{} received {}", thing.getUID(), lightMessage);
432             LightState lightState = lightMessage.state;
433             if (lightState != null) {
434                 if (lastCommandExpireTimestamp > System.currentTimeMillis()
435                         && !lightState.equalsIgnoreNull(lastCommand)) {
436                     // skip for SKIP_UPDATE_TIMESPAN after last command if lightState is different from command
437                     logger.trace("Ignoring differing update after last command until {}", lastCommandExpireTimestamp);
438                     return;
439                 }
440                 lightStateCache = lightState;
441                 if (Boolean.TRUE.equals(lightState.reachable)) {
442                     updateStatus(ThingStatus.ONLINE);
443                     thing.getChannels().stream().map(c -> c.getUID().getId()).forEach(c -> valueUpdated(c, lightState));
444                 } else {
445                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Not reachable");
446                     thing.getChannels().stream().map(c -> c.getUID()).forEach(c -> updateState(c, UnDefType.UNDEF));
447                 }
448             }
449         }
450     }
451 }