]> git.basschouten.com Git - openhab-addons.git/blob
81cce17040ed8338d309508deb009430c53119b1
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.types.ResourceType;
31 import org.openhab.core.library.types.*;
32 import org.openhab.core.library.unit.Units;
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 {
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     private int onTime = 0; // in 0.1s
83     private String colorMode = "";
84
85     // set defaults, we can override them later if we receive better values
86     private int ctMax = ZCL_CT_MAX;
87     private int ctMin = ZCL_CT_MIN;
88
89     public LightThingHandler(Thing thing, Gson gson, StateDescriptionProvider stateDescriptionProvider,
90             CommandDescriptionProvider commandDescriptionProvider) {
91         super(thing, gson, ResourceType.LIGHTS);
92         this.stateDescriptionProvider = stateDescriptionProvider;
93         this.commandDescriptionProvider = commandDescriptionProvider;
94     }
95
96     @Override
97     public void initialize() {
98         if (thing.getThingTypeUID().equals(THING_TYPE_COLOR_TEMPERATURE_LIGHT)
99                 || thing.getThingTypeUID().equals(THING_TYPE_EXTENDED_COLOR_LIGHT)) {
100             try {
101                 Map<String, String> properties = thing.getProperties();
102                 String ctMaxString = properties.get(PROPERTY_CT_MAX);
103                 ctMax = ctMaxString == null ? ZCL_CT_MAX : Integer.parseInt(ctMaxString);
104                 String ctMinString = properties.get(PROPERTY_CT_MIN);
105                 ctMin = ctMinString == null ? ZCL_CT_MIN : Integer.parseInt(ctMinString);
106
107                 // minimum and maximum are inverted due to mired/kelvin conversion!
108                 StateDescription stateDescription = StateDescriptionFragmentBuilder.create()
109                         .withMinimum(new BigDecimal(miredToKelvin(ctMax)))
110                         .withMaximum(new BigDecimal(miredToKelvin(ctMin))).build().toStateDescription();
111                 if (stateDescription != null) {
112                     stateDescriptionProvider.setDescription(new ChannelUID(thing.getUID(), CHANNEL_COLOR_TEMPERATURE),
113                             stateDescription);
114                 } else {
115                     logger.warn("Failed to create state description in thing {}", thing.getUID());
116                 }
117             } catch (NumberFormatException e) {
118                 needsPropertyUpdate = true;
119             }
120         }
121         ThingConfig thingConfig = getConfigAs(ThingConfig.class);
122         colorMode = thingConfig.colormode;
123
124         super.initialize();
125     }
126
127     @Override
128     public void handleCommand(ChannelUID channelUID, Command command) {
129         if (channelUID.getId().equals(CHANNEL_ONTIME)) {
130             if (command instanceof QuantityType<?>) {
131                 QuantityType<?> onTimeSeconds = ((QuantityType<?>) command).toUnit(Units.SECOND);
132                 if (onTimeSeconds != null) {
133                     onTime = 10 * onTimeSeconds.intValue();
134                 } else {
135                     logger.warn("Channel '{}' received command '{}', could not be converted to seconds.", channelUID,
136                             command);
137                 }
138             }
139             return;
140         }
141
142         if (command instanceof RefreshType) {
143             valueUpdated(channelUID.getId(), lightStateCache);
144             return;
145         }
146
147         LightState newLightState = new LightState();
148         Boolean currentOn = lightStateCache.on;
149         Integer currentBri = lightStateCache.bri;
150
151         switch (channelUID.getId()) {
152             case CHANNEL_ALERT:
153                 if (command instanceof StringType) {
154                     newLightState.alert = command.toString();
155                 } else {
156                     return;
157                 }
158                 break;
159             case CHANNEL_EFFECT:
160                 if (command instanceof StringType) {
161                     // effect command only allowed for lights that are turned on
162                     newLightState.on = true;
163                     newLightState.effect = command.toString();
164                 } else {
165                     return;
166                 }
167                 break;
168             case CHANNEL_EFFECT_SPEED:
169                 if (command instanceof DecimalType) {
170                     newLightState.on = true;
171                     newLightState.effectSpeed = Util.constrainToRange(((DecimalType) command).intValue(), 0, 10);
172                 } else {
173                     return;
174                 }
175                 break;
176             case CHANNEL_SWITCH:
177             case CHANNEL_LOCK:
178                 if (command instanceof OnOffType) {
179                     newLightState.on = (command == OnOffType.ON);
180                 } else {
181                     return;
182                 }
183                 break;
184             case CHANNEL_BRIGHTNESS:
185             case CHANNEL_COLOR:
186                 if (command instanceof OnOffType) {
187                     newLightState.on = (command == OnOffType.ON);
188                 } else if (command instanceof IncreaseDecreaseType) {
189                     // try to get best value for current brightness
190                     int oldBri = currentBri != null ? currentBri
191                             : (Boolean.TRUE.equals(currentOn) ? BRIGHTNESS_MAX : BRIGHTNESS_MIN);
192                     if (command.equals(IncreaseDecreaseType.INCREASE)) {
193                         newLightState.bri = Util.constrainToRange(oldBri + BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
194                                 BRIGHTNESS_MAX);
195                     } else {
196                         newLightState.bri = Util.constrainToRange(oldBri - BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
197                                 BRIGHTNESS_MAX);
198                     }
199                 } else if (command instanceof HSBType) {
200                     HSBType hsbCommand = (HSBType) command;
201                     if ("xy".equals(colorMode)) {
202                         PercentType[] xy = hsbCommand.toXY();
203                         if (xy.length < 2) {
204                             logger.warn("Failed to convert {} to xy-values", command);
205                         }
206                         newLightState.xy = new double[] { xy[0].doubleValue() / 100.0, xy[1].doubleValue() / 100.0 };
207                         newLightState.bri = Util.fromPercentType(hsbCommand.getBrightness());
208                     } else {
209                         // default is colormode "hs" (used when colormode "hs" is set or colormode is unknown)
210                         newLightState.bri = Util.fromPercentType(hsbCommand.getBrightness());
211                         newLightState.hue = (int) (hsbCommand.getHue().doubleValue() * HUE_FACTOR);
212                         newLightState.sat = Util.fromPercentType(hsbCommand.getSaturation());
213                     }
214                 } else if (command instanceof PercentType) {
215                     newLightState.bri = Util.fromPercentType((PercentType) command);
216                 } else if (command instanceof DecimalType) {
217                     newLightState.bri = ((DecimalType) command).intValue();
218                 } else {
219                     return;
220                 }
221
222                 // send on/off state together with brightness if not already set or unknown
223                 Integer newBri = newLightState.bri;
224                 if (newBri != null) {
225                     newLightState.on = (newBri > 0);
226                 }
227
228                 // fix sending bri=0 when light is already off
229                 if (newBri != null && newBri == 0 && currentOn != null && !currentOn) {
230                     return;
231                 }
232
233                 Double transitiontime = config.transitiontime;
234                 if (transitiontime != null) {
235                     // value is in 1/10 seconds
236                     newLightState.transitiontime = (int) Math.round(10 * transitiontime);
237                 }
238                 break;
239             case CHANNEL_COLOR_TEMPERATURE:
240                 if (command instanceof DecimalType) {
241                     int miredValue = kelvinToMired(((DecimalType) command).intValue());
242                     newLightState.ct = constrainToRange(miredValue, ctMin, ctMax);
243                     newLightState.on = true;
244                 }
245                 break;
246             case CHANNEL_POSITION:
247                 if (command instanceof UpDownType) {
248                     newLightState.on = (command == UpDownType.DOWN);
249                 } else if (command == StopMoveType.STOP) {
250                     if (currentOn != null && currentOn && currentBri != null && currentBri <= BRIGHTNESS_MAX) {
251                         // going down or currently stop (254 because of rounding error)
252                         newLightState.on = true;
253                     } else if (currentOn != null && !currentOn && currentBri != null && currentBri > BRIGHTNESS_MIN) {
254                         // going up or currently stopped
255                         newLightState.on = false;
256                     }
257                 } else if (command instanceof PercentType) {
258                     newLightState.bri = fromPercentType((PercentType) command);
259                 } else {
260                     return;
261                 }
262                 break;
263             default:
264                 // no supported command
265                 return;
266         }
267
268         Boolean newOn = newLightState.on;
269         if (newOn != null && !newOn) {
270             // if light shall be off, no other commands are allowed, so reset the new light state
271             newLightState.clear();
272             newLightState.on = false;
273         } else if (newOn != null && newOn) {
274             newLightState.ontime = onTime;
275         }
276
277         sendCommand(newLightState, command, channelUID, () -> {
278             Integer transitionTime = newLightState.transitiontime;
279             lastCommandExpireTimestamp = System.currentTimeMillis()
280                     + (transitionTime != null ? transitionTime : DEFAULT_COMMAND_EXPIRY_TIME);
281             lastCommand = newLightState;
282         });
283     }
284
285     @Override
286     protected void processStateResponse(DeconzBaseMessage stateResponse) {
287         if (!(stateResponse instanceof LightMessage)) {
288             return;
289         }
290
291         LightMessage lightMessage = (LightMessage) stateResponse;
292
293         if (needsPropertyUpdate) {
294             // if we did not receive an ctmin/ctmax, then we probably don't need it
295             needsPropertyUpdate = false;
296
297             Integer ctmax = lightMessage.ctmax;
298             Integer ctmin = lightMessage.ctmin;
299             if (ctmin != null && ctmax != null) {
300                 Map<String, String> properties = new HashMap<>(thing.getProperties());
301                 properties.put(PROPERTY_CT_MAX, Integer.toString(Util.constrainToRange(ctmax, ZCL_CT_MIN, ZCL_CT_MAX)));
302                 properties.put(PROPERTY_CT_MIN, Integer.toString(Util.constrainToRange(ctmin, ZCL_CT_MIN, ZCL_CT_MAX)));
303                 updateProperties(properties);
304             }
305         }
306
307         LightState lightState = lightMessage.state;
308         if (lightState != null && lightState.effect != null) {
309             checkAndUpdateEffectChannels(lightMessage);
310         }
311
312         messageReceived(config.id, lightMessage);
313     }
314
315     private enum EffectLightModel {
316         LIDL_MELINARA,
317         TINT_MUELLER,
318         UNKNOWN;
319     }
320
321     private void checkAndUpdateEffectChannels(LightMessage lightMessage) {
322         EffectLightModel model = EffectLightModel.UNKNOWN;
323         // try to determine which model we have
324         if (lightMessage.manufacturername.equals("_TZE200_s8gkrkxk")) {
325             // the LIDL Melinara string does not report a proper model name
326             model = EffectLightModel.LIDL_MELINARA;
327         } else if (lightMessage.manufacturername.equals("MLI")) {
328             model = EffectLightModel.TINT_MUELLER;
329         } else {
330             logger.debug(
331                     "Could not determine effect light type for thing {}, if you feel this is wrong request adding support on GitHub.",
332                     thing.getUID());
333         }
334
335         ChannelUID effectChannelUID = new ChannelUID(thing.getUID(), CHANNEL_EFFECT);
336         ChannelUID effectSpeedChannelUID = new ChannelUID(thing.getUID(), CHANNEL_EFFECT_SPEED);
337
338         if (thing.getChannel(CHANNEL_EFFECT) == null) {
339             ThingBuilder thingBuilder = editThing();
340             thingBuilder.withChannel(
341                     ChannelBuilder.create(effectChannelUID, "String").withType(CHANNEL_EFFECT_TYPE_UID).build());
342             if (model == EffectLightModel.LIDL_MELINARA) {
343                 // additional channels
344                 thingBuilder.withChannel(ChannelBuilder.create(effectSpeedChannelUID, "Number")
345                         .withType(CHANNEL_EFFECT_SPEED_TYPE_UID).build());
346             }
347             updateThing(thingBuilder.build());
348         }
349
350         switch (model) {
351             case LIDL_MELINARA:
352                 List<String> options = List.of("none", "steady", "snow", "rainbow", "snake", "tinkle", "fireworks",
353                         "flag", "waves", "updown", "vintage", "fading", "collide", "strobe", "sparkles", "carnival",
354                         "glow");
355                 commandDescriptionProvider.setDescription(effectChannelUID,
356                         CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
357                 break;
358             case TINT_MUELLER:
359                 options = List.of("none", "colorloop", "sunset", "party", "worklight", "campfire", "romance",
360                         "nightlight");
361                 commandDescriptionProvider.setDescription(effectChannelUID,
362                         CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
363                 break;
364             default:
365                 options = List.of("none", "colorloop");
366                 commandDescriptionProvider.setDescription(effectChannelUID,
367                         CommandDescriptionBuilder.create().withCommandOptions(toCommandOptionList(options)).build());
368
369         }
370     }
371
372     private List<CommandOption> toCommandOptionList(List<String> options) {
373         return options.stream().map(c -> new CommandOption(c, c)).collect(Collectors.toList());
374     }
375
376     private void valueUpdated(String channelId, LightState newState) {
377         Integer bri = newState.bri;
378         Integer hue = newState.hue;
379         Integer sat = newState.sat;
380         Boolean on = newState.on;
381
382         switch (channelId) {
383             case CHANNEL_ALERT:
384                 String alert = newState.alert;
385                 if (alert != null) {
386                     updateState(channelId, new StringType(alert));
387                 }
388                 break;
389             case CHANNEL_SWITCH:
390             case CHANNEL_LOCK:
391                 if (on != null) {
392                     updateState(channelId, OnOffType.from(on));
393                 }
394                 break;
395             case CHANNEL_COLOR:
396                 if (on != null && on == false) {
397                     updateState(channelId, OnOffType.OFF);
398                 } else if (bri != null && "xy".equals(newState.colormode)) {
399                     final double @Nullable [] xy = newState.xy;
400                     if (xy != null && xy.length == 2) {
401                         HSBType color = HSBType.fromXY((float) xy[0], (float) xy[1]);
402                         updateState(channelId, new HSBType(color.getHue(), color.getSaturation(), toPercentType(bri)));
403                     }
404                 } else if (bri != null && hue != null && sat != null) {
405                     updateState(channelId,
406                             new HSBType(new DecimalType(hue / HUE_FACTOR), toPercentType(sat), toPercentType(bri)));
407                 }
408                 break;
409             case CHANNEL_BRIGHTNESS:
410                 if (bri != null && on != null && on) {
411                     updateState(channelId, toPercentType(bri));
412                 } else {
413                     updateState(channelId, OnOffType.OFF);
414                 }
415                 break;
416             case CHANNEL_COLOR_TEMPERATURE:
417                 Integer ct = newState.ct;
418                 if (ct != null && ct >= ctMin && ct <= ctMax) {
419                     updateState(channelId, new DecimalType(miredToKelvin(ct)));
420                 }
421                 break;
422             case CHANNEL_POSITION:
423                 if (bri != null) {
424                     updateState(channelId, toPercentType(bri));
425                 }
426                 break;
427             case CHANNEL_EFFECT:
428                 String effect = newState.effect;
429                 if (effect != null) {
430                     updateState(channelId, new StringType(effect));
431                 }
432                 break;
433             case CHANNEL_EFFECT_SPEED:
434                 Integer effectSpeed = newState.effectSpeed;
435                 if (effectSpeed != null) {
436                     updateState(channelId, new DecimalType(effectSpeed));
437                 }
438                 break;
439             default:
440         }
441     }
442
443     @Override
444     public void messageReceived(String sensorID, DeconzBaseMessage message) {
445         if (message instanceof LightMessage) {
446             LightMessage lightMessage = (LightMessage) message;
447             logger.trace("{} received {}", thing.getUID(), lightMessage);
448             LightState lightState = lightMessage.state;
449             if (lightState != null) {
450                 if (lastCommandExpireTimestamp > System.currentTimeMillis()
451                         && !lightState.equalsIgnoreNull(lastCommand)) {
452                     // skip for SKIP_UPDATE_TIMESPAN after last command if lightState is different from command
453                     logger.trace("Ignoring differing update after last command until {}", lastCommandExpireTimestamp);
454                     return;
455                 }
456                 if (colorMode.isEmpty()) {
457                     String cmode = lightState.colormode;
458                     if (cmode != null && ("hs".equals(cmode) || "xy".equals(cmode))) {
459                         // only set the color mode if it is hs or xy, not ct
460                         colorMode = cmode;
461                     }
462                 }
463                 lightStateCache = lightState;
464                 if (Boolean.TRUE.equals(lightState.reachable)) {
465                     updateStatus(ThingStatus.ONLINE);
466                     thing.getChannels().stream().map(c -> c.getUID().getId()).forEach(c -> valueUpdated(c, lightState));
467                 } else {
468                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Not reachable");
469                     thing.getChannels().stream().map(c -> c.getUID()).forEach(c -> updateState(c, UnDefType.UNDEF));
470                 }
471             }
472         }
473     }
474 }