]> git.basschouten.com Git - openhab-addons.git/blob
ae7114de54d55397bfce384178b03c307ae97a5d
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.Channel;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.ThingTypeUID;
50 import org.openhab.core.thing.binding.builder.ThingBuilder;
51 import org.openhab.core.thing.type.ChannelKind;
52 import org.openhab.core.types.Command;
53 import org.openhab.core.types.CommandOption;
54 import org.openhab.core.types.RefreshType;
55 import org.openhab.core.types.StateDescriptionFragment;
56 import org.openhab.core.types.StateDescriptionFragmentBuilder;
57 import org.openhab.core.util.ColorUtil;
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 DeconzDynamicStateDescriptionProvider stateDescriptionProvider;
89     private final DeconzDynamicCommandDescriptionProvider 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     private @Nullable 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<?> quantity) {
144                 QuantityType<?> onTimeSeconds = quantity.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, 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             }
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             }
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             }
189             case CHANNEL_SWITCH, CHANNEL_LOCK -> {
190                 if (command instanceof OnOffType) {
191                     newLightState.on = (command == OnOffType.ON);
192                 } else {
193                     return;
194                 }
195             }
196             case CHANNEL_BRIGHTNESS, CHANNEL_COLOR -> {
197                 if (command instanceof OnOffType) {
198                     newLightState.on = (command == OnOffType.ON);
199                 } else if (command instanceof IncreaseDecreaseType) {
200                     // try to get best value for current brightness
201                     int oldBri = currentBri != null ? currentBri
202                             : (Boolean.TRUE.equals(currentOn) ? BRIGHTNESS_MAX : BRIGHTNESS_MIN);
203                     if (command.equals(IncreaseDecreaseType.INCREASE)) {
204                         newLightState.bri = Util.constrainToRange(oldBri + BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
205                                 BRIGHTNESS_MAX);
206                     } else {
207                         newLightState.bri = Util.constrainToRange(oldBri - BRIGHTNESS_DIM_STEP, BRIGHTNESS_MIN,
208                                 BRIGHTNESS_MAX);
209                     }
210                 } else if (command instanceof HSBType hsbCommand) {
211                     // XY color is the implicit default: Use XY color mode if i) no color mode is set or ii) if the bulb
212                     // is in CT mode or iii) already in XY mode. Only if the bulb is in HS mode, use this one.
213                     if ("hs".equals(colorMode)) {
214                         newLightState.hue = (int) (hsbCommand.getHue().doubleValue() * HUE_FACTOR);
215                         newLightState.sat = Util.fromPercentType(hsbCommand.getSaturation());
216                         newLightState.bri = Util.fromPercentType(hsbCommand.getBrightness());
217                     } else {
218                         double[] xy = ColorUtil.hsbToXY(hsbCommand);
219                         newLightState.xy = new double[] { xy[0], xy[1] };
220                         newLightState.bri = (int) (xy[2] * BRIGHTNESS_MAX);
221                     }
222                 } else if (command instanceof PercentType) {
223                     newLightState.bri = Util.fromPercentType((PercentType) command);
224                 } else if (command instanceof DecimalType) {
225                     newLightState.bri = ((DecimalType) command).intValue();
226                 } else {
227                     return;
228                 }
229
230                 // send on/off state together with brightness if not already set or unknown
231                 Integer newBri = newLightState.bri;
232                 if (newBri != null) {
233                     newLightState.on = (newBri > 0);
234                 }
235
236                 // fix sending bri=0 when light is already off
237                 if (newBri != null && newBri == 0 && currentOn != null && !currentOn) {
238                     return;
239                 }
240                 Double transitiontime = config.transitiontime;
241                 if (transitiontime != null) {
242                     // value is in 1/10 seconds
243                     newLightState.transitiontime = (int) Math.round(10 * transitiontime);
244                 }
245             }
246             case CHANNEL_COLOR_TEMPERATURE -> {
247                 if (command instanceof DecimalType) {
248                     int miredValue = kelvinToMired(((DecimalType) command).intValue());
249                     newLightState.ct = constrainToRange(miredValue, ctMin, ctMax);
250                     newLightState.on = true;
251                 }
252             }
253             case CHANNEL_POSITION -> {
254                 if (command instanceof UpDownType) {
255                     newLightState.open = (command == UpDownType.UP);
256                 } else if (command == StopMoveType.STOP) {
257                     newLightState.stop = true;
258                 } else if (command instanceof PercentType) {
259                     newLightState.lift = ((PercentType) command).intValue();
260                 } else {
261                     return;
262                 }
263             }
264             default -> {
265                 // no supported command
266                 return;
267             }
268         }
269
270         Boolean newOn = newLightState.on;
271         if (newOn != null && !newOn) {
272             // if light shall be off, no other commands are allowed, so reset the new light state
273             newLightState.clear();
274             newLightState.on = false;
275         } else if (newOn != null && newOn) {
276             newLightState.ontime = onTime;
277         }
278
279         sendCommand(newLightState, command, channelUID, () -> {
280             Integer transitionTime = newLightState.transitiontime;
281             lastCommandExpireTimestamp = System.currentTimeMillis()
282                     + (transitionTime != null ? transitionTime : DEFAULT_COMMAND_EXPIRY_TIME);
283             lastCommand = newLightState;
284         });
285     }
286
287     @Override
288     protected void processStateResponse(DeconzBaseMessage stateResponse) {
289         if (!(stateResponse instanceof LightMessage lightMessage)) {
290             return;
291         }
292
293         if (needsPropertyUpdate) {
294             // if we did not receive a 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         ThingBuilder thingBuilder = editThing();
308         boolean thingEdited = false;
309
310         LightState lightState = lightMessage.state;
311         if (lightState != null && lightState.effect != null
312                 && checkAndUpdateEffectChannels(thingBuilder, lightMessage)) {
313             thingEdited = true;
314         }
315
316         if (checkLastSeen(thingBuilder, stateResponse.lastseen)) {
317             thingEdited = true;
318         }
319         if (thingEdited) {
320             updateThing(thingBuilder.build());
321         }
322
323         messageReceived(lightMessage);
324     }
325
326     private enum EffectLightModel {
327         LIDL_MELINARA,
328         TINT_MUELLER,
329         UNKNOWN
330     }
331
332     private boolean checkAndUpdateEffectChannels(ThingBuilder thingBuilder, LightMessage lightMessage) {
333         // try to determine which model we have
334         EffectLightModel model = switch (lightMessage.manufacturername) {
335             case "_TZE200_s8gkrkxk" -> EffectLightModel.LIDL_MELINARA;
336             case "MLI" -> EffectLightModel.TINT_MUELLER;
337             default -> EffectLightModel.UNKNOWN;
338         };
339         if (model == EffectLightModel.UNKNOWN) {
340             logger.debug(
341                     "Could not determine effect light type for thing {}, if you feel this is wrong request adding support on GitHub.",
342                     thing.getUID());
343         }
344
345         ChannelUID effectChannelUID = new ChannelUID(thing.getUID(), CHANNEL_EFFECT);
346
347         boolean thingEdited = false;
348
349         if (thing.getChannel(CHANNEL_EFFECT) == null) {
350             createChannel(thingBuilder, CHANNEL_EFFECT, ChannelKind.STATE);
351             thingEdited = true;
352         }
353
354         switch (model) {
355             case LIDL_MELINARA:
356                 if (thing.getChannel(CHANNEL_EFFECT_SPEED) == null) {
357                     // additional channels
358                     createChannel(thingBuilder, CHANNEL_EFFECT_SPEED, ChannelKind.STATE);
359                     thingEdited = true;
360                 }
361
362                 List<String> options = List.of("none", "steady", "snow", "rainbow", "snake", "tinkle", "fireworks",
363                         "flag", "waves", "updown", "vintage", "fading", "collide", "strobe", "sparkles", "carnival",
364                         "glow");
365                 commandDescriptionProvider.setCommandOptions(effectChannelUID, toCommandOptionList(options));
366                 break;
367             case TINT_MUELLER:
368                 options = List.of("none", "colorloop", "sunset", "party", "worklight", "campfire", "romance",
369                         "nightlight");
370                 commandDescriptionProvider.setCommandOptions(effectChannelUID, toCommandOptionList(options));
371                 break;
372             default:
373                 options = List.of("none", "colorloop");
374                 commandDescriptionProvider.setCommandOptions(effectChannelUID, toCommandOptionList(options));
375         }
376
377         return thingEdited;
378     }
379
380     private List<CommandOption> toCommandOptionList(List<String> options) {
381         return options.stream().map(c -> new CommandOption(c, c)).collect(Collectors.toList());
382     }
383
384     private void valueUpdated(ChannelUID channelUID, LightState newState) {
385         Boolean on = newState.on;
386
387         switch (channelUID.getId()) {
388             case CHANNEL_ALERT -> updateStringChannel(channelUID, newState.alert);
389             case CHANNEL_SWITCH, CHANNEL_LOCK -> updateSwitchChannel(channelUID, on);
390             case CHANNEL_COLOR -> updateColorChannel(channelUID, newState);
391             case CHANNEL_BRIGHTNESS -> updatePercentTypeChannel(channelUID, newState.bri, newState.on);
392             case CHANNEL_COLOR_TEMPERATURE -> {
393                 Integer ct = newState.ct;
394                 if (ct != null && ct >= ctMin && ct <= ctMax) {
395                     updateState(channelUID, new DecimalType(miredToKelvin(ct)));
396                 }
397             }
398             case CHANNEL_POSITION -> {
399                 Integer lift = newState.lift;
400                 if (lift != null) {
401                     updateState(channelUID, new PercentType(lift));
402                 }
403             }
404             case CHANNEL_EFFECT -> updateStringChannel(channelUID, newState.effect);
405             case CHANNEL_EFFECT_SPEED -> updateDecimalTypeChannel(channelUID, newState.effectSpeed);
406         }
407     }
408
409     @Override
410     public void messageReceived(DeconzBaseMessage message) {
411         logger.trace("{} received {}", thing.getUID(), message);
412         if (message instanceof LightMessage lightMessage) {
413             LightState lightState = lightMessage.state;
414             if (lightState != null) {
415                 if (lastCommandExpireTimestamp > System.currentTimeMillis()
416                         && !lightState.equalsIgnoreNull(lastCommand)) {
417                     // skip for SKIP_UPDATE_TIMESPAN after last command if lightState is different from command
418                     logger.trace("Ignoring differing update after last command until {}", lastCommandExpireTimestamp);
419                     return;
420                 }
421                 if (colorMode.isEmpty()) {
422                     String cmode = lightState.colormode;
423                     if (cmode != null && ("hs".equals(cmode) || "xy".equals(cmode))) {
424                         // only set the color mode if it is hs or xy, not ct
425                         colorMode = cmode;
426                     }
427                 }
428                 lightStateCache = lightState;
429                 if (Boolean.TRUE.equals(lightState.reachable)) {
430                     updateStatus(ThingStatus.ONLINE);
431                     thing.getChannels().stream().map(Channel::getUID).forEach(c -> valueUpdated(c, lightState));
432                 } else {
433                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.sensor-not-reachable");
434                 }
435             }
436         }
437     }
438
439     private void updateColorChannel(ChannelUID channelUID, LightState newState) {
440         Boolean on = newState.on;
441         Integer bri = newState.bri;
442         Integer hue = newState.hue;
443         Integer sat = newState.sat;
444
445         if (on != null && !on) {
446             updateState(channelUID, OnOffType.OFF);
447         } else if (bri != null && "xy".equals(newState.colormode)) {
448             final double @Nullable [] xy = newState.xy;
449             if (xy != null && xy.length == 2) {
450                 double[] xyY = new double[3];
451                 xyY[0] = xy[0];
452                 xyY[1] = xy[1];
453                 xyY[2] = ((double) bri) / BRIGHTNESS_MAX;
454                 updateState(channelUID, ColorUtil.xyToHsb(xyY));
455             }
456         } else if (bri != null && hue != null && sat != null) {
457             updateState(channelUID,
458                     new HSBType(new DecimalType(hue / HUE_FACTOR), toPercentType(sat), toPercentType(bri)));
459         }
460     }
461 }