]> git.basschouten.com Git - openhab-addons.git/blob
af941d2f9669214988bf6f377d239ef5443420b4
[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.lifx.internal.handler;
14
15 import static org.openhab.binding.lifx.internal.LifxBindingConstants.*;
16 import static org.openhab.binding.lifx.internal.protocol.Product.Feature.*;
17 import static org.openhab.binding.lifx.internal.util.LifxMessageUtil.*;
18
19 import java.net.InetSocketAddress;
20 import java.time.Duration;
21 import java.time.LocalDateTime;
22 import java.util.ArrayList;
23 import java.util.Arrays;
24 import java.util.HashMap;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.concurrent.locks.ReentrantLock;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.lifx.internal.LifxBindingConstants;
32 import org.openhab.binding.lifx.internal.LifxChannelFactory;
33 import org.openhab.binding.lifx.internal.LifxLightCommunicationHandler;
34 import org.openhab.binding.lifx.internal.LifxLightConfig;
35 import org.openhab.binding.lifx.internal.LifxLightContext;
36 import org.openhab.binding.lifx.internal.LifxLightCurrentStateUpdater;
37 import org.openhab.binding.lifx.internal.LifxLightOnlineStateUpdater;
38 import org.openhab.binding.lifx.internal.LifxLightPropertiesUpdater;
39 import org.openhab.binding.lifx.internal.LifxLightState;
40 import org.openhab.binding.lifx.internal.LifxLightStateChanger;
41 import org.openhab.binding.lifx.internal.fields.HSBK;
42 import org.openhab.binding.lifx.internal.fields.MACAddress;
43 import org.openhab.binding.lifx.internal.protocol.Effect;
44 import org.openhab.binding.lifx.internal.protocol.GetLightInfraredRequest;
45 import org.openhab.binding.lifx.internal.protocol.GetLightPowerRequest;
46 import org.openhab.binding.lifx.internal.protocol.GetRequest;
47 import org.openhab.binding.lifx.internal.protocol.GetTileEffectRequest;
48 import org.openhab.binding.lifx.internal.protocol.GetWifiInfoRequest;
49 import org.openhab.binding.lifx.internal.protocol.Packet;
50 import org.openhab.binding.lifx.internal.protocol.PowerState;
51 import org.openhab.binding.lifx.internal.protocol.Product;
52 import org.openhab.binding.lifx.internal.protocol.SignalStrength;
53 import org.openhab.core.config.core.Configuration;
54 import org.openhab.core.library.types.DecimalType;
55 import org.openhab.core.library.types.HSBType;
56 import org.openhab.core.library.types.IncreaseDecreaseType;
57 import org.openhab.core.library.types.OnOffType;
58 import org.openhab.core.library.types.PercentType;
59 import org.openhab.core.library.types.StringType;
60 import org.openhab.core.thing.Channel;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.thing.ThingStatusInfo;
66 import org.openhab.core.thing.binding.BaseThingHandler;
67 import org.openhab.core.types.Command;
68 import org.openhab.core.types.RefreshType;
69 import org.openhab.core.types.State;
70 import org.slf4j.Logger;
71 import org.slf4j.LoggerFactory;
72
73 /**
74  * The {@link LifxLightHandler} is responsible for handling commands, which are
75  * sent to one of the light channels.
76  *
77  * @author Dennis Nobel - Initial contribution
78  * @author Stefan Bußweiler - Added new thing status handling
79  * @author Karel Goderis - Rewrite for Firmware V2, and remove dependency on external libraries
80  * @author Kai Kreuzer - Added configurable transition time and small fixes
81  * @author Wouter Born - Decomposed class into separate objects
82  * @author Pauli Anttila - Added power on temperature and color features.
83  */
84 @NonNullByDefault
85 public class LifxLightHandler extends BaseThingHandler {
86
87     private final Logger logger = LoggerFactory.getLogger(LifxLightHandler.class);
88
89     private static final Duration MIN_STATUS_INFO_UPDATE_INTERVAL = Duration.ofSeconds(1);
90     private static final Duration MAX_STATE_CHANGE_DURATION = Duration.ofSeconds(4);
91
92     private final LifxChannelFactory channelFactory;
93     private @NonNullByDefault({}) Product product;
94
95     private @Nullable PercentType powerOnBrightness;
96     private @Nullable HSBType powerOnColor;
97     private @Nullable PercentType powerOnTemperature;
98     private Double effectMorphSpeed = 3.0;
99     private Double effectFlameSpeed = 4.0;
100
101     private @NonNullByDefault({}) String logId;
102
103     private final ReentrantLock lock = new ReentrantLock();
104
105     private @NonNullByDefault({}) CurrentLightState currentLightState;
106     private @NonNullByDefault({}) LifxLightState pendingLightState;
107
108     private Map<String, @Nullable State> channelStates = new HashMap<>();
109     private @Nullable ThingStatusInfo statusInfo;
110     private LocalDateTime lastStatusInfoUpdate = LocalDateTime.MIN;
111
112     private @NonNullByDefault({}) LifxLightCommunicationHandler communicationHandler;
113     private @NonNullByDefault({}) LifxLightCurrentStateUpdater currentStateUpdater;
114     private @NonNullByDefault({}) LifxLightStateChanger lightStateChanger;
115     private @NonNullByDefault({}) LifxLightOnlineStateUpdater onlineStateUpdater;
116     private @NonNullByDefault({}) LifxLightPropertiesUpdater propertiesUpdater;
117
118     public class CurrentLightState extends LifxLightState {
119
120         public boolean isOnline() {
121             return thing.getStatus() == ThingStatus.ONLINE;
122         }
123
124         public boolean isOffline() {
125             return thing.getStatus() == ThingStatus.OFFLINE;
126         }
127
128         public void setOnline() {
129             updateStatusIfChanged(ThingStatus.ONLINE);
130         }
131
132         public void setOnline(MACAddress macAddress) {
133             updateStatusIfChanged(ThingStatus.ONLINE);
134             Configuration configuration = editConfiguration();
135             configuration.put(LifxBindingConstants.CONFIG_PROPERTY_DEVICE_ID, macAddress.getAsLabel());
136             updateConfiguration(configuration);
137         }
138
139         public void setOffline() {
140             updateStatusIfChanged(ThingStatus.OFFLINE);
141         }
142
143         public void setOfflineByCommunicationError() {
144             updateStatusIfChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
145         }
146
147         @Override
148         public void setColors(HSBK[] colors) {
149             if (!isStateChangePending() || isPendingColorStateChangesApplied(getPowerState(), colors)) {
150                 PowerState powerState = isStateChangePending() ? pendingLightState.getPowerState() : getPowerState();
151                 updateColorChannels(powerState, colors);
152             }
153             super.setColors(colors);
154         }
155
156         @Override
157         public void setPowerState(PowerState powerState) {
158             if (!isStateChangePending() || isPendingColorStateChangesApplied(powerState, getColors())) {
159                 HSBK[] colors = isStateChangePending() ? pendingLightState.getColors() : getColors();
160                 updateColorChannels(powerState, colors);
161             }
162             super.setPowerState(powerState);
163         }
164
165         private boolean isPendingColorStateChangesApplied(@Nullable PowerState powerState, HSBK[] colors) {
166             return powerState != null && powerState.equals(pendingLightState.getPowerState())
167                     && Arrays.equals(colors, pendingLightState.getColors());
168         }
169
170         private void updateColorChannels(@Nullable PowerState powerState, HSBK[] colors) {
171             HSBK color = colors.length > 0 ? colors[0] : null;
172             HSBK updateColor = nullSafeUpdateColor(powerState, color);
173             HSBType hsb = updateColor.getHSB();
174
175             updateStateIfChanged(CHANNEL_COLOR, hsb);
176             updateStateIfChanged(CHANNEL_BRIGHTNESS, hsb.getBrightness());
177             updateStateIfChanged(CHANNEL_TEMPERATURE,
178                     kelvinToPercentType(updateColor.getKelvin(), product.getTemperatureRange()));
179
180             updateZoneChannels(powerState, colors);
181         }
182
183         private HSBK nullSafeUpdateColor(@Nullable PowerState powerState, @Nullable HSBK color) {
184             HSBK updateColor = color != null ? color : DEFAULT_COLOR;
185             if (powerState == PowerState.OFF) {
186                 updateColor = new HSBK(updateColor);
187                 updateColor.setBrightness(PercentType.ZERO);
188             }
189             return updateColor;
190         }
191
192         @Override
193         public void setInfrared(PercentType infrared) {
194             if (!isStateChangePending() || infrared.equals(pendingLightState.getInfrared())) {
195                 updateStateIfChanged(CHANNEL_INFRARED, infrared);
196             }
197             super.setInfrared(infrared);
198         }
199
200         @Override
201         public void setSignalStrength(SignalStrength signalStrength) {
202             updateStateIfChanged(CHANNEL_SIGNAL_STRENGTH, new DecimalType(signalStrength.toQualityRating()));
203             super.setSignalStrength(signalStrength);
204         }
205
206         @Override
207         public void setTileEffect(Effect effect) {
208             updateStateIfChanged(CHANNEL_EFFECT, new StringType(effect.getType().stringValue()));
209             super.setTileEffect(effect);
210         }
211
212         private void updateZoneChannels(@Nullable PowerState powerState, HSBK[] colors) {
213             if (!product.hasFeature(MULTIZONE) || colors.length == 0) {
214                 return;
215             }
216
217             int oldZones = getColors().length;
218             int newZones = colors.length;
219             if (oldZones != newZones) {
220                 addRemoveZoneChannels(newZones);
221             }
222
223             for (int i = 0; i < colors.length; i++) {
224                 HSBK color = colors[i];
225                 HSBK updateColor = nullSafeUpdateColor(powerState, color);
226                 updateStateIfChanged(CHANNEL_COLOR_ZONE + i, updateColor.getHSB());
227                 updateStateIfChanged(CHANNEL_TEMPERATURE_ZONE + i,
228                         kelvinToPercentType(updateColor.getKelvin(), product.getTemperatureRange()));
229             }
230         }
231     }
232
233     public LifxLightHandler(Thing thing, LifxChannelFactory channelFactory) {
234         super(thing);
235         this.channelFactory = channelFactory;
236     }
237
238     @Override
239     public void initialize() {
240         try {
241             lock.lock();
242
243             LifxLightConfig configuration = getConfigAs(LifxLightConfig.class);
244
245             logId = getLogId(configuration.getMACAddress(), configuration.getHost());
246             product = getProduct();
247
248             logger.debug("{} : Initializing handler for product {}", logId, product.getName());
249
250             powerOnBrightness = getPowerOnBrightness();
251             powerOnColor = getPowerOnColor();
252             powerOnTemperature = getPowerOnTemperature();
253             Double speed = getEffectSpeed(LifxBindingConstants.CONFIG_PROPERTY_EFFECT_MORPH_SPEED);
254             if (speed != null) {
255                 effectMorphSpeed = speed;
256             }
257             speed = getEffectSpeed(LifxBindingConstants.CONFIG_PROPERTY_EFFECT_FLAME_SPEED);
258             if (speed != null) {
259                 effectFlameSpeed = speed;
260             }
261             channelStates.clear();
262             currentLightState = new CurrentLightState();
263             pendingLightState = new LifxLightState();
264
265             LifxLightContext context = new LifxLightContext(logId, product, configuration, currentLightState,
266                     pendingLightState, scheduler);
267
268             communicationHandler = new LifxLightCommunicationHandler(context);
269             currentStateUpdater = new LifxLightCurrentStateUpdater(context, communicationHandler);
270             onlineStateUpdater = new LifxLightOnlineStateUpdater(context, communicationHandler);
271             propertiesUpdater = new LifxLightPropertiesUpdater(context, communicationHandler);
272             propertiesUpdater.addPropertiesUpdateListener(this::updateProperties);
273             lightStateChanger = new LifxLightStateChanger(context, communicationHandler);
274
275             if (configuration.getMACAddress() != null || configuration.getHost() != null) {
276                 communicationHandler.start();
277                 currentStateUpdater.start();
278                 onlineStateUpdater.start();
279                 propertiesUpdater.start();
280                 lightStateChanger.start();
281                 startOrStopSignalStrengthUpdates();
282             } else {
283                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
284                         "Configure a Device ID or Host");
285             }
286         } catch (Exception e) {
287             logger.debug("{} : Error occurred while initializing handler: {}", logId, e.getMessage(), e);
288             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
289         } finally {
290             lock.unlock();
291         }
292     }
293
294     @Override
295     public void dispose() {
296         try {
297             lock.lock();
298
299             logger.debug("{} : Disposing handler", logId);
300
301             if (communicationHandler != null) {
302                 communicationHandler.stop();
303                 communicationHandler = null;
304             }
305
306             if (currentStateUpdater != null) {
307                 currentStateUpdater.stop();
308                 currentStateUpdater = null;
309             }
310
311             if (onlineStateUpdater != null) {
312                 onlineStateUpdater.stop();
313                 onlineStateUpdater = null;
314             }
315
316             if (propertiesUpdater != null) {
317                 propertiesUpdater.stop();
318                 propertiesUpdater.removePropertiesUpdateListener(this::updateProperties);
319                 propertiesUpdater = null;
320             }
321
322             if (lightStateChanger != null) {
323                 lightStateChanger.stop();
324                 lightStateChanger = null;
325             }
326
327             currentLightState = null;
328             pendingLightState = null;
329         } finally {
330             lock.unlock();
331         }
332     }
333
334     public String getLogId(@Nullable MACAddress macAddress, @Nullable InetSocketAddress host) {
335         return (macAddress != null ? macAddress.getHex() : (host != null ? host.getHostString() : "Unknown"));
336     }
337
338     private @Nullable PercentType getPowerOnBrightness() {
339         Channel channel = null;
340
341         if (product.hasFeature(COLOR)) {
342             ChannelUID channelUID = new ChannelUID(getThing().getUID(), LifxBindingConstants.CHANNEL_COLOR);
343             channel = getThing().getChannel(channelUID.getId());
344         } else {
345             ChannelUID channelUID = new ChannelUID(getThing().getUID(), LifxBindingConstants.CHANNEL_BRIGHTNESS);
346             channel = getThing().getChannel(channelUID.getId());
347         }
348
349         if (channel == null) {
350             return null;
351         }
352
353         Configuration configuration = channel.getConfiguration();
354         Object powerOnBrightness = configuration.get(LifxBindingConstants.CONFIG_PROPERTY_POWER_ON_BRIGHTNESS);
355         return powerOnBrightness == null ? null : new PercentType(powerOnBrightness.toString());
356     }
357
358     private @Nullable HSBType getPowerOnColor() {
359         Channel channel = null;
360
361         if (product.hasFeature(COLOR)) {
362             ChannelUID channelUID = new ChannelUID(getThing().getUID(), LifxBindingConstants.CHANNEL_COLOR);
363             channel = getThing().getChannel(channelUID.getId());
364         }
365
366         if (channel == null) {
367             return null;
368         }
369
370         Configuration configuration = channel.getConfiguration();
371         Object powerOnColor = configuration.get(LifxBindingConstants.CONFIG_PROPERTY_POWER_ON_COLOR);
372         return powerOnColor == null ? null : new HSBType(powerOnColor.toString());
373     }
374
375     private @Nullable PercentType getPowerOnTemperature() {
376         ChannelUID channelUID = new ChannelUID(getThing().getUID(), LifxBindingConstants.CHANNEL_TEMPERATURE);
377         Channel channel = getThing().getChannel(channelUID.getId());
378
379         if (channel == null) {
380             return null;
381         }
382
383         Configuration configuration = channel.getConfiguration();
384         Object powerOnTemperature = configuration.get(LifxBindingConstants.CONFIG_PROPERTY_POWER_ON_TEMPERATURE);
385         if (powerOnTemperature != null) {
386             return new PercentType(powerOnTemperature.toString());
387         }
388         return null;
389     }
390
391     private @Nullable Double getEffectSpeed(String parameter) {
392         Channel channel = null;
393
394         if (product.hasFeature(TILE_EFFECT)) {
395             ChannelUID channelUID = new ChannelUID(getThing().getUID(), LifxBindingConstants.CHANNEL_EFFECT);
396             channel = getThing().getChannel(channelUID.getId());
397         }
398
399         if (channel == null) {
400             return null;
401         }
402
403         Configuration configuration = channel.getConfiguration();
404         Object speed = configuration.get(parameter);
405         return speed == null ? null : new Double(speed.toString());
406     }
407
408     private Product getProduct() {
409         Object propertyValue = getThing().getProperties().get(LifxBindingConstants.PROPERTY_PRODUCT_ID);
410         if (propertyValue == null) {
411             return Product.getLikelyProduct(getThing().getThingTypeUID());
412         }
413         try {
414             // Without first conversion to double, on a very first thing creation from discovery inbox,
415             // the product type is incorrectly parsed, as framework passed it as a floating point number
416             // (e.g. 50.0 instead of 50)
417             Double d = Double.parseDouble((String) propertyValue);
418             long productID = d.longValue();
419             return Product.getProductFromProductID(productID);
420         } catch (IllegalArgumentException e) {
421             return Product.getLikelyProduct(getThing().getThingTypeUID());
422         }
423     }
424
425     private void addRemoveZoneChannels(int zones) {
426         List<Channel> newChannels = new ArrayList<>();
427
428         // retain non-zone channels
429         for (Channel channel : getThing().getChannels()) {
430             String channelId = channel.getUID().getId();
431             if (!channelId.startsWith(CHANNEL_COLOR_ZONE) && !channelId.startsWith(CHANNEL_TEMPERATURE_ZONE)) {
432                 newChannels.add(channel);
433             }
434         }
435
436         // add zone channels
437         for (int i = 0; i < zones; i++) {
438             newChannels.add(channelFactory.createColorZoneChannel(getThing().getUID(), i));
439             newChannels.add(channelFactory.createTemperatureZoneChannel(getThing().getUID(), i));
440         }
441
442         updateThing(editThing().withChannels(newChannels).build());
443
444         Map<String, String> properties = editProperties();
445         properties.put(LifxBindingConstants.PROPERTY_ZONES, Integer.toString(zones));
446         updateProperties(properties);
447     }
448
449     @Override
450     public void channelLinked(ChannelUID channelUID) {
451         super.channelLinked(channelUID);
452         startOrStopSignalStrengthUpdates();
453     }
454
455     @Override
456     public void channelUnlinked(ChannelUID channelUID) {
457         startOrStopSignalStrengthUpdates();
458     }
459
460     private void startOrStopSignalStrengthUpdates() {
461         currentStateUpdater.setUpdateSignalStrength(isLinked(CHANNEL_SIGNAL_STRENGTH));
462     }
463
464     private void sendPacket(Packet packet) {
465         communicationHandler.sendPacket(packet);
466     }
467
468     @Override
469     public void handleCommand(ChannelUID channelUID, Command command) {
470         if (command instanceof RefreshType) {
471             channelStates.remove(channelUID.getId());
472             switch (channelUID.getId()) {
473                 case CHANNEL_COLOR:
474                 case CHANNEL_BRIGHTNESS:
475                     sendPacket(new GetLightPowerRequest());
476                     sendPacket(new GetRequest());
477                     break;
478                 case CHANNEL_TEMPERATURE:
479                     sendPacket(new GetRequest());
480                     break;
481                 case CHANNEL_INFRARED:
482                     sendPacket(new GetLightInfraredRequest());
483                     break;
484                 case CHANNEL_SIGNAL_STRENGTH:
485                     sendPacket(new GetWifiInfoRequest());
486                     break;
487                 case CHANNEL_EFFECT:
488                     if (product.hasFeature(TILE_EFFECT)) {
489                         sendPacket(new GetTileEffectRequest());
490                     }
491                     break;
492                 default:
493                     break;
494             }
495         } else {
496             boolean supportedCommand = true;
497             switch (channelUID.getId()) {
498                 case CHANNEL_COLOR:
499                     if (command instanceof HSBType) {
500                         handleHSBCommand((HSBType) command);
501                     } else if (command instanceof PercentType) {
502                         handlePercentCommand((PercentType) command);
503                     } else if (command instanceof OnOffType) {
504                         handleOnOffCommand((OnOffType) command);
505                     } else if (command instanceof IncreaseDecreaseType) {
506                         handleIncreaseDecreaseCommand((IncreaseDecreaseType) command);
507                     } else {
508                         supportedCommand = false;
509                     }
510                     break;
511                 case CHANNEL_BRIGHTNESS:
512                     if (command instanceof PercentType) {
513                         handlePercentCommand((PercentType) command);
514                     } else if (command instanceof OnOffType) {
515                         handleOnOffCommand((OnOffType) command);
516                     } else if (command instanceof IncreaseDecreaseType) {
517                         handleIncreaseDecreaseCommand((IncreaseDecreaseType) command);
518                     } else {
519                         supportedCommand = false;
520                     }
521                     break;
522                 case CHANNEL_TEMPERATURE:
523                     if (command instanceof PercentType) {
524                         handleTemperatureCommand((PercentType) command);
525                     } else if (command instanceof IncreaseDecreaseType) {
526                         handleIncreaseDecreaseTemperatureCommand((IncreaseDecreaseType) command);
527                     } else {
528                         supportedCommand = false;
529                     }
530                     break;
531                 case CHANNEL_INFRARED:
532                     if (command instanceof PercentType) {
533                         handleInfraredCommand((PercentType) command);
534                     } else if (command instanceof IncreaseDecreaseType) {
535                         handleIncreaseDecreaseInfraredCommand((IncreaseDecreaseType) command);
536                     } else {
537                         supportedCommand = false;
538                     }
539                     break;
540                 case CHANNEL_EFFECT:
541                     if (command instanceof StringType && product.hasFeature(TILE_EFFECT)) {
542                         handleTileEffectCommand((StringType) command);
543                     } else {
544                         supportedCommand = false;
545                     }
546                     break;
547                 default:
548                     try {
549                         if (channelUID.getId().startsWith(CHANNEL_COLOR_ZONE)) {
550                             int zoneIndex = Integer.parseInt(channelUID.getId().replace(CHANNEL_COLOR_ZONE, ""));
551                             if (command instanceof HSBType) {
552                                 handleHSBCommand((HSBType) command, zoneIndex);
553                             } else if (command instanceof PercentType) {
554                                 handlePercentCommand((PercentType) command, zoneIndex);
555                             } else if (command instanceof IncreaseDecreaseType) {
556                                 handleIncreaseDecreaseCommand((IncreaseDecreaseType) command, zoneIndex);
557                             } else {
558                                 supportedCommand = false;
559                             }
560                         } else if (channelUID.getId().startsWith(CHANNEL_TEMPERATURE_ZONE)) {
561                             int zoneIndex = Integer.parseInt(channelUID.getId().replace(CHANNEL_TEMPERATURE_ZONE, ""));
562                             if (command instanceof PercentType) {
563                                 handleTemperatureCommand((PercentType) command, zoneIndex);
564                             } else if (command instanceof IncreaseDecreaseType) {
565                                 handleIncreaseDecreaseTemperatureCommand((IncreaseDecreaseType) command, zoneIndex);
566                             } else {
567                                 supportedCommand = false;
568                             }
569                         } else {
570                             supportedCommand = false;
571                         }
572                     } catch (NumberFormatException e) {
573                         logger.error("Failed to parse zone index for a command of a light ({}) : {}", logId,
574                                 e.getMessage());
575                         supportedCommand = false;
576                     }
577                     break;
578             }
579
580             if (supportedCommand && !(command instanceof OnOffType) && !CHANNEL_INFRARED.equals(channelUID.getId())) {
581                 getLightStateForCommand().setPowerState(PowerState.ON);
582             }
583         }
584     }
585
586     private LifxLightState getLightStateForCommand() {
587         if (!isStateChangePending()) {
588             pendingLightState.copy(currentLightState);
589         }
590         return pendingLightState;
591     }
592
593     private boolean isStateChangePending() {
594         return pendingLightState.getDurationSinceLastChange().minus(MAX_STATE_CHANGE_DURATION).isNegative();
595     }
596
597     private void handleTemperatureCommand(PercentType temperature) {
598         HSBK newColor = getLightStateForCommand().getColor();
599         newColor.setSaturation(PercentType.ZERO);
600         newColor.setKelvin(percentTypeToKelvin(temperature, product.getTemperatureRange()));
601         getLightStateForCommand().setColor(newColor);
602     }
603
604     private void handleTemperatureCommand(PercentType temperature, int zoneIndex) {
605         HSBK newColor = getLightStateForCommand().getColor(zoneIndex);
606         newColor.setSaturation(PercentType.ZERO);
607         newColor.setKelvin(percentTypeToKelvin(temperature, product.getTemperatureRange()));
608         getLightStateForCommand().setColor(newColor, zoneIndex);
609     }
610
611     private void handleHSBCommand(HSBType hsb) {
612         getLightStateForCommand().setColor(hsb);
613     }
614
615     private void handleHSBCommand(HSBType hsb, int zoneIndex) {
616         getLightStateForCommand().setColor(hsb, zoneIndex);
617     }
618
619     private void handlePercentCommand(PercentType brightness) {
620         getLightStateForCommand().setBrightness(brightness);
621     }
622
623     private void handlePercentCommand(PercentType brightness, int zoneIndex) {
624         getLightStateForCommand().setBrightness(brightness, zoneIndex);
625     }
626
627     private void handleOnOffCommand(OnOffType onOff) {
628         HSBType localPowerOnColor = powerOnColor;
629         if (localPowerOnColor != null && onOff == OnOffType.ON) {
630             getLightStateForCommand().setColor(localPowerOnColor);
631         }
632
633         PercentType localPowerOnTemperature = powerOnTemperature;
634         if (localPowerOnTemperature != null && onOff == OnOffType.ON) {
635             getLightStateForCommand()
636                     .setTemperature(percentTypeToKelvin(localPowerOnTemperature, product.getTemperatureRange()));
637         }
638
639         PercentType powerOnBrightness = this.powerOnBrightness;
640         if (powerOnBrightness != null) {
641             PercentType newBrightness = onOff == OnOffType.ON ? powerOnBrightness : new PercentType(0);
642             getLightStateForCommand().setBrightness(newBrightness);
643         }
644         getLightStateForCommand().setPowerState(onOff);
645     }
646
647     private void handleIncreaseDecreaseCommand(IncreaseDecreaseType increaseDecrease) {
648         HSBK baseColor = getLightStateForCommand().getColor();
649         PercentType newBrightness = increaseDecreasePercentType(increaseDecrease, baseColor.getHSB().getBrightness());
650         handlePercentCommand(newBrightness);
651     }
652
653     private void handleIncreaseDecreaseCommand(IncreaseDecreaseType increaseDecrease, int zoneIndex) {
654         HSBK baseColor = getLightStateForCommand().getColor(zoneIndex);
655         PercentType newBrightness = increaseDecreasePercentType(increaseDecrease, baseColor.getHSB().getBrightness());
656         handlePercentCommand(newBrightness, zoneIndex);
657     }
658
659     private void handleIncreaseDecreaseTemperatureCommand(IncreaseDecreaseType increaseDecrease) {
660         PercentType baseTemperature = kelvinToPercentType(getLightStateForCommand().getColor().getKelvin(),
661                 product.getTemperatureRange());
662         PercentType newTemperature = increaseDecreasePercentType(increaseDecrease, baseTemperature);
663         handleTemperatureCommand(newTemperature);
664     }
665
666     private void handleIncreaseDecreaseTemperatureCommand(IncreaseDecreaseType increaseDecrease, int zoneIndex) {
667         PercentType baseTemperature = kelvinToPercentType(getLightStateForCommand().getColor(zoneIndex).getKelvin(),
668                 product.getTemperatureRange());
669         PercentType newTemperature = increaseDecreasePercentType(increaseDecrease, baseTemperature);
670         handleTemperatureCommand(newTemperature, zoneIndex);
671     }
672
673     private void handleInfraredCommand(PercentType infrared) {
674         getLightStateForCommand().setInfrared(infrared);
675     }
676
677     private void handleIncreaseDecreaseInfraredCommand(IncreaseDecreaseType increaseDecrease) {
678         PercentType baseInfrared = getLightStateForCommand().getInfrared();
679         if (baseInfrared != null) {
680             PercentType newInfrared = increaseDecreasePercentType(increaseDecrease, baseInfrared);
681             handleInfraredCommand(newInfrared);
682         }
683     }
684
685     private void handleTileEffectCommand(StringType type) {
686         logger.debug("handleTileEffectCommand mode={}", type);
687         Double morphSpeedInMSecs = effectMorphSpeed * 1000.0;
688         Double flameSpeedInMSecs = effectFlameSpeed * 1000.0;
689         try {
690             Effect effect = Effect.createDefault(type.toString(), morphSpeedInMSecs.longValue(),
691                     flameSpeedInMSecs.longValue());
692             getLightStateForCommand().setTileEffect(effect);
693         } catch (IllegalArgumentException e) {
694             logger.debug("Wrong effect type received as command: {}", type);
695         }
696     }
697
698     private void updateStateIfChanged(String channel, State newState) {
699         State oldState = channelStates.get(channel);
700         if (oldState == null || !oldState.equals(newState)) {
701             updateState(channel, newState);
702             channelStates.put(channel, newState);
703         }
704     }
705
706     private void updateStatusIfChanged(ThingStatus status) {
707         updateStatusIfChanged(status, ThingStatusDetail.NONE);
708     }
709
710     private void updateStatusIfChanged(ThingStatus status, ThingStatusDetail statusDetail) {
711         ThingStatusInfo newStatusInfo = new ThingStatusInfo(status, statusDetail, null);
712         Duration durationSinceLastUpdate = Duration.between(lastStatusInfoUpdate, LocalDateTime.now());
713         boolean intervalElapsed = MIN_STATUS_INFO_UPDATE_INTERVAL.minus(durationSinceLastUpdate).isNegative();
714
715         ThingStatusInfo oldStatusInfo = statusInfo;
716         if (oldStatusInfo == null || !oldStatusInfo.equals(newStatusInfo) || intervalElapsed) {
717             statusInfo = newStatusInfo;
718             lastStatusInfoUpdate = LocalDateTime.now();
719             updateStatus(status, statusDetail);
720         }
721     }
722 }