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.*;
17 import java.math.BigDecimal;
18 import java.util.List;
20 import java.util.Objects;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.stream.Collectors;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.hue.internal.dto.ColorTemperature;
29 import org.openhab.binding.hue.internal.dto.FullGroup;
30 import org.openhab.binding.hue.internal.dto.Scene;
31 import org.openhab.binding.hue.internal.dto.State;
32 import org.openhab.binding.hue.internal.dto.StateUpdate;
33 import org.openhab.core.library.types.DecimalType;
34 import org.openhab.core.library.types.HSBType;
35 import org.openhab.core.library.types.IncreaseDecreaseType;
36 import org.openhab.core.library.types.OnOffType;
37 import org.openhab.core.library.types.PercentType;
38 import org.openhab.core.library.types.QuantityType;
39 import org.openhab.core.library.types.StringType;
40 import org.openhab.core.library.unit.Units;
41 import org.openhab.core.thing.Bridge;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.ThingStatusInfo;
47 import org.openhab.core.thing.ThingTypeUID;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.thing.binding.ThingHandler;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.StateOption;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
56 * {@link HueGroupHandler} is the handler for a hue group of lights. It uses the {@link HueClient} to execute the
59 * @author Laurent Garnier - Initial contribution
62 public class HueGroupHandler extends BaseThingHandler implements HueLightActionsHandler, GroupStatusListener {
64 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_GROUP);
65 public static final String PROPERTY_MEMBERS = "members";
67 private final Logger logger = LoggerFactory.getLogger(HueGroupHandler.class);
68 private final HueStateDescriptionProvider stateDescriptionOptionProvider;
70 private @NonNullByDefault({}) String groupId;
72 private @Nullable Integer lastSentColorTemp;
73 private @Nullable Integer lastSentBrightness;
75 private ColorTemperature colorTemperatureCapabilties = new ColorTemperature();
76 private long defaultFadeTime = 400;
78 private @Nullable HueClient hueClient;
80 private @Nullable ScheduledFuture<?> scheduledFuture;
81 private @Nullable FullGroup lastFullGroup;
83 private List<String> consoleScenesList = List.of();
85 public HueGroupHandler(Thing thing, HueStateDescriptionProvider stateDescriptionOptionProvider) {
87 this.stateDescriptionOptionProvider = stateDescriptionOptionProvider;
91 public void initialize() {
92 logger.debug("Initializing Hue group handler.");
93 Bridge bridge = getBridge();
94 initializeThing((bridge == null) ? null : bridge.getStatus());
98 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
99 logger.debug("bridgeStatusChanged {}", bridgeStatusInfo);
100 initializeThing(bridgeStatusInfo.getStatus());
103 private void initializeThing(@Nullable ThingStatus bridgeStatus) {
104 logger.debug("initializeThing thing {} bridge status {}", getThing().getUID(), bridgeStatus);
105 final String configGroupId = (String) getConfig().get(GROUP_ID);
106 if (configGroupId != null) {
107 BigDecimal time = (BigDecimal) getConfig().get(FADETIME);
109 defaultFadeTime = time.longValueExact();
112 groupId = configGroupId;
113 // note: this call implicitly registers our handler as a listener on the bridge
114 if (getHueClient() != null) {
115 if (bridgeStatus == ThingStatus.ONLINE) {
116 updateStatus(ThingStatus.ONLINE);
118 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
121 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
124 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
125 "@text/offline.conf-error-no-group-id");
129 private synchronized void initializeProperties(@Nullable FullGroup fullGroup) {
130 if (fullGroup != null) {
131 Map<String, String> properties = editProperties();
132 properties.put(PROPERTY_MEMBERS, fullGroup.getLightIds().stream().collect(Collectors.joining(",")));
133 updateProperties(properties);
138 public void dispose() {
139 logger.debug("Hue group handler disposes. Unregistering listener.");
140 cancelScheduledFuture();
141 if (groupId != null) {
142 HueClient bridgeHandler = getHueClient();
143 if (bridgeHandler != null) {
144 bridgeHandler.unregisterGroupStatusListener(this);
151 protected synchronized @Nullable HueClient getHueClient() {
152 if (hueClient == null) {
153 Bridge bridge = getBridge();
154 if (bridge == null) {
157 ThingHandler handler = bridge.getHandler();
158 if (handler instanceof HueBridgeHandler) {
159 HueClient bridgeHandler = (HueClient) handler;
160 hueClient = bridgeHandler;
161 bridgeHandler.registerGroupStatusListener(this);
170 public void handleCommand(ChannelUID channelUID, Command command) {
171 handleCommand(channelUID.getId(), command, defaultFadeTime);
175 public void handleCommand(String channel, Command command, long fadeTime) {
176 HueClient bridgeHandler = getHueClient();
177 if (bridgeHandler == null) {
178 logger.debug("Hue Bridge handler not found. Cannot handle command without bridge.");
182 FullGroup group = bridgeHandler.getGroupById(groupId);
184 logger.debug("Hue group not known on bridge. Cannot handle command.");
185 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
186 "@text/offline.conf-error-wrong-group-id");
190 Integer lastColorTemp;
191 StateUpdate newState = null;
194 if (command instanceof HSBType) {
195 HSBType hsbCommand = (HSBType) command;
196 if (hsbCommand.getBrightness().intValue() == 0) {
197 newState = LightStateConverter.toOnOffLightState(OnOffType.OFF);
199 newState = LightStateConverter.toColorLightState(hsbCommand, group.getState());
200 newState.setOn(true);
201 newState.setTransitionTime(fadeTime);
203 } else if (command instanceof PercentType) {
204 newState = LightStateConverter.toBrightnessLightState((PercentType) command);
205 newState.setTransitionTime(fadeTime);
206 } else if (command instanceof OnOffType) {
207 newState = LightStateConverter.toOnOffLightState((OnOffType) command);
208 } else if (command instanceof IncreaseDecreaseType) {
209 newState = convertBrightnessChangeToStateUpdate((IncreaseDecreaseType) command, group);
210 if (newState != null) {
211 newState.setTransitionTime(fadeTime);
215 case CHANNEL_COLORTEMPERATURE:
216 if (command instanceof PercentType) {
217 newState = LightStateConverter.toColorTemperatureLightStateFromPercentType((PercentType) command,
218 colorTemperatureCapabilties);
219 newState.setTransitionTime(fadeTime);
220 } else if (command instanceof OnOffType) {
221 newState = LightStateConverter.toOnOffLightState((OnOffType) command);
222 } else if (command instanceof IncreaseDecreaseType) {
223 newState = convertColorTempChangeToStateUpdate((IncreaseDecreaseType) command, group);
224 if (newState != null) {
225 newState.setTransitionTime(fadeTime);
229 case CHANNEL_COLORTEMPERATURE_ABS:
230 if (command instanceof QuantityType) {
231 QuantityType<?> convertedCommand = ((QuantityType<?>) command).toInvertibleUnit(Units.KELVIN);
232 if (convertedCommand != null) {
233 newState = LightStateConverter.toColorTemperatureLightState(convertedCommand.intValue(),
234 colorTemperatureCapabilties);
235 newState.setTransitionTime(fadeTime);
237 logger.warn("Unable to convert unit from '{}' to '{}'. Skipping command.",
238 ((QuantityType<?>) command).getUnit(), Units.KELVIN);
240 } else if (command instanceof DecimalType) {
241 newState = LightStateConverter.toColorTemperatureLightState(((DecimalType) command).intValue(),
242 colorTemperatureCapabilties);
243 newState.setTransitionTime(fadeTime);
246 case CHANNEL_BRIGHTNESS:
247 if (command instanceof PercentType) {
248 newState = LightStateConverter.toBrightnessLightState((PercentType) command);
249 newState.setTransitionTime(fadeTime);
250 } else if (command instanceof OnOffType) {
251 newState = LightStateConverter.toOnOffLightState((OnOffType) command);
252 } else if (command instanceof IncreaseDecreaseType) {
253 newState = convertBrightnessChangeToStateUpdate((IncreaseDecreaseType) command, group);
254 if (newState != null) {
255 newState.setTransitionTime(fadeTime);
258 lastColorTemp = lastSentColorTemp;
259 if (newState != null && lastColorTemp != null) {
260 // make sure that the light also has the latest color temp
261 // this might not have been yet set in the light, if it was off
262 newState.setColorTemperature(lastColorTemp, colorTemperatureCapabilties);
263 newState.setTransitionTime(fadeTime);
267 if (command instanceof OnOffType) {
268 newState = LightStateConverter.toOnOffLightState((OnOffType) command);
270 lastColorTemp = lastSentColorTemp;
271 if (newState != null && lastColorTemp != null) {
272 // make sure that the light also has the latest color temp
273 // this might not have been yet set in the light, if it was off
274 newState.setColorTemperature(lastColorTemp, colorTemperatureCapabilties);
275 newState.setTransitionTime(fadeTime);
279 if (command instanceof StringType) {
280 newState = LightStateConverter.toAlertState((StringType) command);
281 if (newState == null) {
282 // Unsupported StringType is passed. Log a warning
283 // message and return.
284 logger.warn("Unsupported String command: {}. Supported commands are: {}, {}, {} ", command,
285 LightStateConverter.ALERT_MODE_NONE, LightStateConverter.ALERT_MODE_SELECT,
286 LightStateConverter.ALERT_MODE_LONG_SELECT);
289 scheduleAlertStateRestore(command);
294 if (command instanceof StringType) {
295 newState = new StateUpdate().setScene(command.toString());
299 logger.debug("Command sent to an unknown channel id: {}:{}", getThing().getUID(), channel);
302 if (newState != null) {
303 cacheNewState(newState);
304 bridgeHandler.updateGroupState(group, newState, fadeTime);
306 logger.debug("Unable to handle command '{}' for channel '{}'. Skipping command.", command, channel);
311 * Caches the new state that is sent to the bridge. This is necessary in case the lights are off when the values are
312 * sent. In this case, the values are not yet set in the lights.
314 * @param newState the state to be cached
316 private void cacheNewState(StateUpdate newState) {
317 Integer tmpBrightness = newState.getBrightness();
318 if (tmpBrightness != null) {
319 lastSentBrightness = tmpBrightness;
321 Integer tmpColorTemp = newState.getColorTemperature();
322 if (tmpColorTemp != null) {
323 lastSentColorTemp = tmpColorTemp;
327 private @Nullable StateUpdate convertColorTempChangeToStateUpdate(IncreaseDecreaseType command, FullGroup group) {
328 StateUpdate stateUpdate = null;
329 Integer currentColorTemp = getCurrentColorTemp(group.getState());
330 if (currentColorTemp != null) {
331 int newColorTemp = LightStateConverter.toAdjustedColorTemp(command, currentColorTemp,
332 colorTemperatureCapabilties);
333 stateUpdate = new StateUpdate().setColorTemperature(newColorTemp, colorTemperatureCapabilties);
338 private @Nullable Integer getCurrentColorTemp(@Nullable State groupState) {
339 Integer colorTemp = lastSentColorTemp;
340 if (colorTemp == null && groupState != null) {
341 return groupState.getColorTemperature();
346 private @Nullable StateUpdate convertBrightnessChangeToStateUpdate(IncreaseDecreaseType command, FullGroup group) {
347 Integer currentBrightness = getCurrentBrightness(group.getState());
348 if (currentBrightness == null) {
351 int newBrightness = LightStateConverter.toAdjustedBrightness(command, currentBrightness);
352 return createBrightnessStateUpdate(currentBrightness, newBrightness);
355 private @Nullable Integer getCurrentBrightness(@Nullable State groupState) {
356 if (lastSentBrightness == null && groupState != null) {
357 return groupState.isOn() ? groupState.getBrightness() : 0;
359 return lastSentBrightness;
362 private StateUpdate createBrightnessStateUpdate(int currentBrightness, int newBrightness) {
363 StateUpdate lightUpdate = new StateUpdate();
364 if (newBrightness == 0) {
365 lightUpdate.turnOff();
367 lightUpdate.setBrightness(newBrightness);
368 if (currentBrightness == 0) {
369 lightUpdate.turnOn();
376 public void channelLinked(ChannelUID channelUID) {
377 HueClient handler = getHueClient();
378 if (handler != null) {
379 FullGroup group = handler.getGroupById(groupId);
381 onGroupStateChanged(group);
387 public boolean onGroupStateChanged(FullGroup group) {
388 logger.trace("onGroupStateChanged() was called for group {}", group.getId());
390 State state = group.getState();
392 final FullGroup lastState = lastFullGroup;
393 if (lastState == null || !Objects.equals(lastState.getState(), state)) {
394 lastFullGroup = group;
399 logger.trace("New state for group {}", groupId);
401 initializeProperties(group);
403 lastSentColorTemp = null;
404 lastSentBrightness = null;
406 updateStatus(ThingStatus.ONLINE);
408 logger.debug("onGroupStateChanged Group {}: on {} bri {} hue {} sat {} temp {} mode {} XY {}", group.getName(),
409 state.isOn(), state.getBrightness(), state.getHue(), state.getSaturation(), state.getColorTemperature(),
410 state.getColorMode(), state.getXY());
412 HSBType hsbType = LightStateConverter.toHSBType(state);
414 hsbType = new HSBType(hsbType.getHue(), hsbType.getSaturation(), PercentType.ZERO);
416 updateState(CHANNEL_COLOR, hsbType);
418 PercentType brightnessPercentType = state.isOn() ? LightStateConverter.toBrightnessPercentType(state)
420 updateState(CHANNEL_BRIGHTNESS, brightnessPercentType);
422 updateState(CHANNEL_SWITCH, OnOffType.from(state.isOn()));
424 updateState(CHANNEL_COLORTEMPERATURE,
425 LightStateConverter.toColorTemperaturePercentType(state, colorTemperatureCapabilties));
426 updateState(CHANNEL_COLORTEMPERATURE_ABS, LightStateConverter.toColorTemperature(state));
432 public void onGroupAdded(FullGroup group) {
433 onGroupStateChanged(group);
437 public void onGroupRemoved() {
438 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.group-removed");
442 public void onGroupGone() {
443 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/offline.group-removed");
447 * Sets the state options for applicable scenes.
450 public void onScenesUpdated(List<Scene> updatedScenes) {
451 List<StateOption> stateOptions = List.of();
452 consoleScenesList = List.of();
453 HueClient handler = getHueClient();
454 if (handler != null) {
455 FullGroup group = handler.getGroupById(groupId);
457 stateOptions = updatedScenes.stream().filter(scene -> scene.isApplicableTo(group))
458 .map(Scene::toStateOption).toList();
459 consoleScenesList = updatedScenes.stream().filter(scene -> scene.isApplicableTo(group))
460 .map(scene -> "Id is \"" + scene.getId() + "\" for scene \"" + scene.toStateOption().getLabel()
465 stateDescriptionOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SCENE),
470 * Schedules restoration of the alert item state to {@link LightStateConverter#ALERT_MODE_NONE} after a given time.
472 * Based on the initial command:
474 * <li>For {@link LightStateConverter#ALERT_MODE_SELECT} restoration will be triggered after <strong>2
476 * <li>For {@link LightStateConverter#ALERT_MODE_LONG_SELECT} restoration will be triggered after <strong>15
479 * This method also cancels any previously scheduled restoration.
481 * @param command The {@link Command} sent to the item
483 private void scheduleAlertStateRestore(Command command) {
484 cancelScheduledFuture();
485 int delay = getAlertDuration(command);
488 scheduledFuture = scheduler.schedule(() -> {
489 updateState(CHANNEL_ALERT, new StringType(LightStateConverter.ALERT_MODE_NONE));
490 }, delay, TimeUnit.MILLISECONDS);
495 * This method will cancel previously scheduled alert item state
498 private void cancelScheduledFuture() {
499 ScheduledFuture<?> scheduledJob = scheduledFuture;
500 if (scheduledJob != null) {
501 scheduledJob.cancel(true);
502 scheduledFuture = null;
507 * This method returns the time in <strong>milliseconds</strong> after
508 * which, the state of the alert item has to be restored to {@link LightStateConverter#ALERT_MODE_NONE}.
510 * @param command The initial command sent to the alert item.
511 * @return Based on the initial command will return:
513 * <li><strong>2000</strong> for {@link LightStateConverter#ALERT_MODE_SELECT}.
514 * <li><strong>15000</strong> for {@link LightStateConverter#ALERT_MODE_LONG_SELECT}.
515 * <li><strong>-1</strong> for any command different from the previous two.
518 private int getAlertDuration(Command command) {
520 switch (command.toString()) {
521 case LightStateConverter.ALERT_MODE_LONG_SELECT:
524 case LightStateConverter.ALERT_MODE_SELECT:
535 public List<String> listScenesForConsole() {
536 return consoleScenesList;
540 public String getGroupId() {