2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.hue.internal.handler;
15 import static org.openhab.binding.hue.internal.HueBindingConstants.*;
16 import static org.openhab.core.thing.Thing.*;
18 import java.math.BigDecimal;
20 import java.util.Objects;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.openhab.binding.hue.internal.dto.Capabilities;
28 import org.openhab.binding.hue.internal.dto.ColorTemperature;
29 import org.openhab.binding.hue.internal.dto.FullLight;
30 import org.openhab.binding.hue.internal.dto.State;
31 import org.openhab.binding.hue.internal.dto.StateUpdate;
32 import org.openhab.core.library.types.DecimalType;
33 import org.openhab.core.library.types.HSBType;
34 import org.openhab.core.library.types.IncreaseDecreaseType;
35 import org.openhab.core.library.types.OnOffType;
36 import org.openhab.core.library.types.PercentType;
37 import org.openhab.core.library.types.QuantityType;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.library.unit.Units;
40 import org.openhab.core.thing.Bridge;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.Thing;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.thing.ThingStatusInfo;
46 import org.openhab.core.thing.ThingTypeUID;
47 import org.openhab.core.thing.binding.BaseThingHandler;
48 import org.openhab.core.thing.binding.ThingHandler;
49 import org.openhab.core.types.Command;
50 import org.openhab.core.types.StateDescriptionFragment;
51 import org.openhab.core.types.StateDescriptionFragmentBuilder;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
56 * {@link HueLightHandler} is the handler for a Hue light. It uses the {@link HueClient} to execute the actual
59 * @author Dennis Nobel - Initial contribution
60 * @author Oliver Libutzki - Adjustments
61 * @author Kai Kreuzer - stabilized code
62 * @author Andre Fuechsel - implemented switch off when brightness == 0, changed to support generic thing types, changed
63 * the initialization of properties
64 * @author Thomas Höfer - added thing properties
65 * @author Jochen Hiller - fixed status updates for reachable=true/false
66 * @author Markus Mazurczak - added code for command handling of OSRAM PAR16 50
68 * @author Yordan Zhelev - added alert and effect functions
69 * @author Denis Dudnik - switched to internally integrated source of Jue library
70 * @author Christoph Weitkamp - Added support for bulbs using CIE XY colormode only
71 * @author Jochen Leopold - Added support for custom fade times
72 * @author Jacob Laursen - Add workaround for LK Wiser products
75 public class HueLightHandler extends BaseThingHandler implements HueLightActionsHandler, LightStatusListener {
77 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_COLOR_LIGHT,
78 THING_TYPE_COLOR_TEMPERATURE_LIGHT, THING_TYPE_DIMMABLE_LIGHT, THING_TYPE_EXTENDED_COLOR_LIGHT,
79 THING_TYPE_ON_OFF_LIGHT, THING_TYPE_ON_OFF_PLUG, THING_TYPE_DIMMABLE_PLUG);
81 public static final String OSRAM_PAR16_50_TW_MODEL_ID = "PAR16_50_TW";
82 public static final String LK_WISER_MODEL_ID = "LK_Dimmer";
84 private final Logger logger = LoggerFactory.getLogger(HueLightHandler.class);
86 private final HueStateDescriptionProvider stateDescriptionProvider;
88 private @NonNullByDefault({}) String lightId;
90 private @Nullable FullLight lastFullLight;
91 private long endBypassTime = 0L;
93 private @Nullable Integer lastSentColorTemp;
94 private @Nullable Integer lastSentBrightness;
97 * Flag to indicate whether the bulb is of type Osram par16 50 TW
99 private boolean isOsramPar16 = false;
101 * Flag to indicate whether the dimmer/relay is of type LK Wiser by Schneider Electric
103 private boolean isLkWiser = false;
105 private boolean propertiesInitializedSuccessfully = false;
106 private boolean capabilitiesInitializedSuccessfully = false;
107 private ColorTemperature colorTemperatureCapabilties = new ColorTemperature();
108 private long defaultFadeTime = 400;
110 private @Nullable HueClient hueClient;
112 private @Nullable ScheduledFuture<?> scheduledFuture;
114 public HueLightHandler(Thing hueLight, HueStateDescriptionProvider stateDescriptionProvider) {
116 this.stateDescriptionProvider = stateDescriptionProvider;
120 public void initialize() {
121 logger.debug("Initializing Hue light handler.");
122 Bridge bridge = getBridge();
123 initializeThing((bridge == null) ? null : bridge.getStatus());
127 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
128 logger.debug("bridgeStatusChanged {}", bridgeStatusInfo);
129 initializeThing(bridgeStatusInfo.getStatus());
132 private void initializeThing(@Nullable ThingStatus bridgeStatus) {
133 logger.debug("initializeThing thing {} bridge status {}", getThing().getUID(), bridgeStatus);
134 final String configLightId = (String) getConfig().get(LIGHT_ID);
135 if (configLightId != null) {
136 BigDecimal time = (BigDecimal) getConfig().get(FADETIME);
138 defaultFadeTime = time.longValueExact();
141 lightId = configLightId;
142 // note: this call implicitly registers our handler as a listener on the bridge
143 HueClient bridgeHandler = getHueClient();
144 if (bridgeHandler != null) {
145 if (bridgeStatus == ThingStatus.ONLINE) {
146 FullLight fullLight = bridgeHandler.getLightById(lightId);
147 initializeProperties(fullLight);
148 initializeCapabilities(fullLight);
149 updateStatus(ThingStatus.ONLINE);
151 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
154 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
157 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
158 "@text/offline.conf-error-no-light-id");
162 private synchronized void initializeProperties(@Nullable FullLight fullLight) {
163 if (!propertiesInitializedSuccessfully && fullLight != null) {
164 Map<String, String> properties = editProperties();
165 String softwareVersion = fullLight.getSoftwareVersion();
166 if (softwareVersion != null) {
167 properties.put(PROPERTY_FIRMWARE_VERSION, softwareVersion);
169 String modelId = fullLight.getNormalizedModelID();
170 if (modelId != null) {
171 properties.put(PROPERTY_MODEL_ID, modelId);
174 case OSRAM_PAR16_50_TW_MODEL_ID:
177 case LK_WISER_MODEL_ID:
182 properties.put(PROPERTY_VENDOR, fullLight.getManufacturerName());
183 properties.put(PRODUCT_NAME, fullLight.getProductName());
184 String uniqueID = fullLight.getUniqueID();
185 if (uniqueID != null) {
186 properties.put(UNIQUE_ID, uniqueID);
188 updateProperties(properties);
189 propertiesInitializedSuccessfully = true;
193 private void initializeCapabilities(@Nullable FullLight fullLight) {
194 if (!capabilitiesInitializedSuccessfully && fullLight != null) {
195 Capabilities capabilities = fullLight.capabilities;
196 if (capabilities != null) {
197 ColorTemperature ct = capabilities.control.ct;
199 colorTemperatureCapabilties = ct;
201 // minimum and maximum are inverted due to mired/Kelvin conversion!
202 StateDescriptionFragment stateDescriptionFragment = StateDescriptionFragmentBuilder.create()
203 .withMinimum(new BigDecimal(LightStateConverter.miredToKelvin(ct.max))) //
204 .withMaximum(new BigDecimal(LightStateConverter.miredToKelvin(ct.min))) //
205 .withStep(new BigDecimal(100)) //
206 .withPattern("%.0f K") //
208 stateDescriptionProvider.setStateDescriptionFragment(
209 new ChannelUID(thing.getUID(), CHANNEL_COLORTEMPERATURE_ABS), stateDescriptionFragment);
212 capabilitiesInitializedSuccessfully = true;
217 public void dispose() {
218 logger.debug("Hue light handler disposes. Unregistering listener.");
219 cancelScheduledFuture();
220 if (lightId != null) {
221 HueClient bridgeHandler = getHueClient();
222 if (bridgeHandler != null) {
223 bridgeHandler.unregisterLightStatusListener(this);
231 public void handleCommand(ChannelUID channelUID, Command command) {
232 handleCommand(channelUID.getId(), command, defaultFadeTime);
236 public void handleCommand(String channel, Command command, long fadeTime) {
237 HueClient bridgeHandler = getHueClient();
238 if (bridgeHandler == null) {
239 logger.warn("Hue Bridge handler not found. Cannot handle command without bridge.");
243 final FullLight light = lastFullLight == null ? bridgeHandler.getLightById(lightId) : lastFullLight;
245 logger.debug("Hue light not known on bridge. Cannot handle command.");
246 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
247 "@text/offline.conf-error-wrong-light-id");
251 Integer lastColorTemp;
252 StateUpdate newState = null;
254 case CHANNEL_COLORTEMPERATURE:
255 if (command instanceof PercentType) {
256 newState = LightStateConverter.toColorTemperatureLightStateFromPercentType((PercentType) command,
257 colorTemperatureCapabilties);
258 newState.setTransitionTime(fadeTime);
259 } else if (command instanceof OnOffType) {
260 newState = LightStateConverter.toOnOffLightState((OnOffType) command);
262 newState = addOsramSpecificCommands(newState, (OnOffType) command);
264 } else if (command instanceof IncreaseDecreaseType) {
265 newState = convertColorTempChangeToStateUpdate((IncreaseDecreaseType) command, light);
266 if (newState != null) {
267 newState.setTransitionTime(fadeTime);
271 case CHANNEL_COLORTEMPERATURE_ABS:
272 if (command instanceof QuantityType) {
273 QuantityType<?> convertedCommand = ((QuantityType<?>) command).toInvertibleUnit(Units.KELVIN);
274 if (convertedCommand != null) {
275 newState = LightStateConverter.toColorTemperatureLightState(convertedCommand.intValue(),
276 colorTemperatureCapabilties);
277 newState.setTransitionTime(fadeTime);
279 logger.warn("Unable to convert unit from '{}' to '{}'. Skipping command.",
280 ((QuantityType<?>) command).getUnit(), Units.KELVIN);
282 } else if (command instanceof DecimalType) {
283 newState = LightStateConverter.toColorTemperatureLightState(((DecimalType) command).intValue(),
284 colorTemperatureCapabilties);
285 newState.setTransitionTime(fadeTime);
288 case CHANNEL_BRIGHTNESS:
289 if (command instanceof PercentType) {
290 newState = LightStateConverter.toBrightnessLightState((PercentType) command);
291 newState.setTransitionTime(fadeTime);
292 } else if (command instanceof OnOffType) {
293 newState = LightStateConverter.toOnOffLightState((OnOffType) command);
295 newState = addOsramSpecificCommands(newState, (OnOffType) command);
296 } else if (isLkWiser) {
297 newState = addLkWiserSpecificCommands(newState, (OnOffType) command);
299 } else if (command instanceof IncreaseDecreaseType) {
300 newState = convertBrightnessChangeToStateUpdate((IncreaseDecreaseType) command, light);
301 if (newState != null) {
302 newState.setTransitionTime(fadeTime);
305 lastColorTemp = lastSentColorTemp;
306 if (newState != null && lastColorTemp != null) {
307 // make sure that the light also has the latest color temp
308 // this might not have been yet set in the light, if it was off
309 newState.setColorTemperature(lastColorTemp, colorTemperatureCapabilties);
310 newState.setTransitionTime(fadeTime);
314 if (command instanceof OnOffType) {
315 newState = LightStateConverter.toOnOffLightState((OnOffType) command);
317 newState = addOsramSpecificCommands(newState, (OnOffType) command);
318 } else if (isLkWiser) {
319 newState = addLkWiserSpecificCommands(newState, (OnOffType) command);
322 lastColorTemp = lastSentColorTemp;
323 if (newState != null && lastColorTemp != null) {
324 // make sure that the light also has the latest color temp
325 // this might not have been yet set in the light, if it was off
326 newState.setColorTemperature(lastColorTemp, colorTemperatureCapabilties);
327 newState.setTransitionTime(fadeTime);
331 if (command instanceof HSBType) {
332 HSBType hsbCommand = (HSBType) command;
333 if (hsbCommand.getBrightness().intValue() == 0) {
334 newState = LightStateConverter.toOnOffLightState(OnOffType.OFF);
336 newState = LightStateConverter.toColorLightState(hsbCommand, light.getState());
337 newState.setTransitionTime(fadeTime);
339 } else if (command instanceof PercentType) {
340 newState = LightStateConverter.toBrightnessLightState((PercentType) command);
341 newState.setTransitionTime(fadeTime);
342 } else if (command instanceof OnOffType) {
343 newState = LightStateConverter.toOnOffLightState((OnOffType) command);
344 } else if (command instanceof IncreaseDecreaseType) {
345 newState = convertBrightnessChangeToStateUpdate((IncreaseDecreaseType) command, light);
346 if (newState != null) {
347 newState.setTransitionTime(fadeTime);
352 if (command instanceof StringType) {
353 newState = LightStateConverter.toAlertState((StringType) command);
354 if (newState == null) {
355 // Unsupported StringType is passed. Log a warning
356 // message and return.
357 logger.warn("Unsupported String command: {}. Supported commands are: {}, {}, {} ", command,
358 LightStateConverter.ALERT_MODE_NONE, LightStateConverter.ALERT_MODE_SELECT,
359 LightStateConverter.ALERT_MODE_LONG_SELECT);
362 scheduleAlertStateRestore(command);
367 if (command instanceof OnOffType) {
368 newState = LightStateConverter.toOnOffEffectState((OnOffType) command);
372 logger.debug("Command sent to an unknown channel id: {}:{}", getThing().getUID(), channel);
375 if (newState != null) {
376 // Cache values which we have sent
377 Integer tmpBrightness = newState.getBrightness();
378 if (tmpBrightness != null) {
379 lastSentBrightness = tmpBrightness;
381 Integer tmpColorTemp = newState.getColorTemperature();
382 if (tmpColorTemp != null) {
383 lastSentColorTemp = tmpColorTemp;
385 bridgeHandler.updateLightState(this, light, newState, fadeTime);
387 logger.debug("Unable to handle command '{}' for channel '{}'. Skipping command.", command, channel);
392 * Applies additional {@link StateUpdate} commands as a workaround for Osram
393 * Lightify PAR16 TW firmware bug. Also see
394 * http://www.everyhue.com/vanilla/discussion/1756/solved-lightify-turning-off
396 private StateUpdate addOsramSpecificCommands(StateUpdate lightState, OnOffType actionType) {
397 if (actionType.equals(OnOffType.ON)) {
398 lightState.setBrightness(254);
400 lightState.setTransitionTime(0);
406 * Applies additional {@link StateUpdate} commands as a workaround for LK Wiser
407 * Dimmer/Relay firmware bug. Additional details here:
408 * https://techblog.vindvejr.dk/?p=455
410 private StateUpdate addLkWiserSpecificCommands(StateUpdate lightState, OnOffType actionType) {
411 if (actionType.equals(OnOffType.OFF)) {
412 lightState.setTransitionTime(0);
417 private @Nullable StateUpdate convertColorTempChangeToStateUpdate(IncreaseDecreaseType command, FullLight light) {
418 StateUpdate stateUpdate = null;
419 Integer currentColorTemp = getCurrentColorTemp(light.getState());
420 if (currentColorTemp != null) {
421 int newColorTemp = LightStateConverter.toAdjustedColorTemp(command, currentColorTemp,
422 colorTemperatureCapabilties);
423 stateUpdate = new StateUpdate().setColorTemperature(newColorTemp, colorTemperatureCapabilties);
428 private @Nullable Integer getCurrentColorTemp(@Nullable State lightState) {
429 Integer colorTemp = lastSentColorTemp;
430 if (colorTemp == null && lightState != null) {
431 return lightState.getColorTemperature();
436 private @Nullable StateUpdate convertBrightnessChangeToStateUpdate(IncreaseDecreaseType command, FullLight light) {
437 Integer currentBrightness = getCurrentBrightness(light.getState());
438 if (currentBrightness == null) {
441 int newBrightness = LightStateConverter.toAdjustedBrightness(command, currentBrightness);
442 return createBrightnessStateUpdate(currentBrightness, newBrightness);
445 private @Nullable Integer getCurrentBrightness(@Nullable State lightState) {
446 if (lastSentBrightness == null && lightState != null) {
447 return lightState.isOn() ? lightState.getBrightness() : 0;
449 return lastSentBrightness;
452 private StateUpdate createBrightnessStateUpdate(int currentBrightness, int newBrightness) {
453 StateUpdate lightUpdate = new StateUpdate();
454 if (newBrightness == 0) {
455 lightUpdate.turnOff();
457 lightUpdate.setBrightness(newBrightness);
458 if (currentBrightness == 0) {
459 lightUpdate.turnOn();
465 protected synchronized @Nullable HueClient getHueClient() {
466 if (hueClient == null) {
467 Bridge bridge = getBridge();
468 if (bridge == null) {
471 ThingHandler handler = bridge.getHandler();
472 if (handler instanceof HueClient) {
473 HueClient bridgeHandler = (HueClient) handler;
474 hueClient = bridgeHandler;
475 bridgeHandler.registerLightStatusListener(this);
484 public void setPollBypass(long bypassTime) {
485 endBypassTime = System.currentTimeMillis() + bypassTime;
489 public void unsetPollBypass() {
494 public boolean onLightStateChanged(FullLight fullLight) {
495 logger.trace("onLightStateChanged() was called");
497 if (System.currentTimeMillis() <= endBypassTime) {
498 logger.debug("Bypass light update after command ({}).", lightId);
502 State state = fullLight.getState();
504 final FullLight lastState = lastFullLight;
505 if (lastState == null || !Objects.equals(lastState.getState(), state)) {
506 lastFullLight = fullLight;
511 logger.trace("New state for light {}", lightId);
513 initializeProperties(fullLight);
515 lastSentColorTemp = null;
516 lastSentBrightness = null;
518 // update status (ONLINE, OFFLINE)
519 if (state.isReachable()) {
520 updateStatus(ThingStatus.ONLINE);
522 // we assume OFFLINE without any error (NONE), as this is an
523 // expected state (when bulb powered off)
524 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.light-not-reachable");
527 logger.debug("onLightStateChanged Light {}: on {} bri {} hue {} sat {} temp {} mode {} XY {}",
528 fullLight.getName(), state.isOn(), state.getBrightness(), state.getHue(), state.getSaturation(),
529 state.getColorTemperature(), state.getColorMode(), state.getXY());
531 HSBType hsbType = LightStateConverter.toHSBType(state);
533 hsbType = new HSBType(hsbType.getHue(), hsbType.getSaturation(), PercentType.ZERO);
535 updateState(CHANNEL_COLOR, hsbType);
537 PercentType brightnessPercentType = state.isOn() ? LightStateConverter.toBrightnessPercentType(state)
539 updateState(CHANNEL_BRIGHTNESS, brightnessPercentType);
541 updateState(CHANNEL_SWITCH, OnOffType.from(state.isOn()));
543 updateState(CHANNEL_COLORTEMPERATURE,
544 LightStateConverter.toColorTemperaturePercentType(state, colorTemperatureCapabilties));
545 updateState(CHANNEL_COLORTEMPERATURE_ABS, LightStateConverter.toColorTemperature(state));
547 StringType stringType = LightStateConverter.toAlertStringType(state);
548 if (!"NULL".equals(stringType.toString())) {
549 updateState(CHANNEL_ALERT, stringType);
550 scheduleAlertStateRestore(stringType);
557 public void channelLinked(ChannelUID channelUID) {
558 HueClient handler = getHueClient();
559 if (handler != null) {
560 FullLight light = handler.getLightById(lightId);
562 onLightStateChanged(light);
568 public void onLightRemoved() {
569 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.light-removed");
573 public void onLightGone() {
574 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/offline.light-not-reachable");
578 public void onLightAdded(FullLight light) {
579 onLightStateChanged(light);
583 * Schedules restoration of the alert item state to {@link LightStateConverter#ALERT_MODE_NONE} after a given time.
585 * Based on the initial command:
587 * <li>For {@link LightStateConverter#ALERT_MODE_SELECT} restoration will be triggered after <strong>2
589 * <li>For {@link LightStateConverter#ALERT_MODE_LONG_SELECT} restoration will be triggered after <strong>15
592 * This method also cancels any previously scheduled restoration.
594 * @param command The {@link Command} sent to the item
596 private void scheduleAlertStateRestore(Command command) {
597 cancelScheduledFuture();
598 int delay = getAlertDuration(command);
601 scheduledFuture = scheduler.schedule(() -> {
602 updateState(CHANNEL_ALERT, new StringType(LightStateConverter.ALERT_MODE_NONE));
603 }, delay, TimeUnit.MILLISECONDS);
608 * This method will cancel previously scheduled alert item state
611 private void cancelScheduledFuture() {
612 ScheduledFuture<?> scheduledJob = scheduledFuture;
613 if (scheduledJob != null) {
614 scheduledJob.cancel(true);
615 scheduledFuture = null;
620 * This method returns the time in <strong>milliseconds</strong> after
621 * which, the state of the alert item has to be restored to {@link LightStateConverter#ALERT_MODE_NONE}.
623 * @param command The initial command sent to the alert item.
624 * @return Based on the initial command will return:
626 * <li><strong>2000</strong> for {@link LightStateConverter#ALERT_MODE_SELECT}.
627 * <li><strong>15000</strong> for {@link LightStateConverter#ALERT_MODE_LONG_SELECT}.
628 * <li><strong>-1</strong> for any command different from the previous two.
631 private int getAlertDuration(Command command) {
633 switch (command.toString()) {
634 case LightStateConverter.ALERT_MODE_LONG_SELECT:
637 case LightStateConverter.ALERT_MODE_SELECT:
649 public String getLightId() {