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