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