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