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