2 * Copyright (c) 2010-2024 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.mqtt.homeassistant.internal.component;
15 import java.math.BigDecimal;
16 import java.util.Objects;
18 import org.eclipse.jdt.annotation.NonNullByDefault;
19 import org.eclipse.jdt.annotation.Nullable;
20 import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
21 import org.openhab.binding.mqtt.generic.mapping.ColorMode;
22 import org.openhab.binding.mqtt.generic.values.ColorValue;
23 import org.openhab.binding.mqtt.generic.values.TextValue;
24 import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
25 import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType;
26 import org.openhab.core.library.types.DecimalType;
27 import org.openhab.core.library.types.HSBType;
28 import org.openhab.core.library.types.OnOffType;
29 import org.openhab.core.library.types.PercentType;
30 import org.openhab.core.library.types.StringType;
31 import org.openhab.core.thing.ChannelUID;
32 import org.openhab.core.thing.type.AutoUpdatePolicy;
33 import org.openhab.core.types.Command;
34 import org.openhab.core.types.State;
35 import org.openhab.core.types.UnDefType;
38 * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
40 * Specifically, the default schema. This class will present a single channel for color, brightness,
41 * or on/off as appropriate. Additional attributes are still exposed as dedicated channels.
43 * @author Cody Cutrer - Initial contribution
46 public class DefaultSchemaLight extends Light {
47 protected static final String HS_CHANNEL_ID = "hs";
48 protected static final String RGB_CHANNEL_ID = "rgb";
49 protected static final String RGBW_CHANNEL_ID = "rgbw";
50 protected static final String RGBWW_CHANNEL_ID = "rgbww";
51 protected static final String XY_CHANNEL_ID = "xy";
52 protected static final String WHITE_CHANNEL_ID = "white";
54 protected @Nullable ComponentChannel hsChannel;
55 protected @Nullable ComponentChannel rgbChannel;
56 protected @Nullable ComponentChannel xyChannel;
58 public DefaultSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) {
59 super(builder, newStyleChannels);
63 protected void buildChannels() {
64 AutoUpdatePolicy autoUpdatePolicy = optimistic ? AutoUpdatePolicy.RECOMMEND : null;
65 ComponentChannel localOnOffChannel;
66 localOnOffChannel = onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue,
68 .stateTopic(channelConfiguration.stateTopic, channelConfiguration.stateValueTemplate)
69 .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
70 channelConfiguration.getQos())
71 .withAutoUpdatePolicy(autoUpdatePolicy).commandFilter(this::handleRawOnOffCommand).build(false);
74 ComponentChannel localBrightnessChannel = null;
75 if (channelConfiguration.brightnessStateTopic != null || channelConfiguration.brightnessCommandTopic != null) {
76 localBrightnessChannel = brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID,
77 ComponentChannelType.DIMMER, brightnessValue, "Brightness", this)
78 .stateTopic(channelConfiguration.brightnessStateTopic, channelConfiguration.brightnessValueTemplate)
79 .commandTopic(channelConfiguration.brightnessCommandTopic, channelConfiguration.isRetain(),
80 channelConfiguration.getQos())
81 .withAutoUpdatePolicy(autoUpdatePolicy).withFormat("%.0f")
82 .commandFilter(this::handleBrightnessCommand).build(false);
85 if (channelConfiguration.whiteCommandTopic != null) {
86 buildChannel(WHITE_CHANNEL_ID, ComponentChannelType.DIMMER, brightnessValue,
87 "Go directly to white of a specific brightness", this)
88 .commandTopic(channelConfiguration.whiteCommandTopic, channelConfiguration.isRetain(),
89 channelConfiguration.getQos())
90 .withAutoUpdatePolicy(autoUpdatePolicy).isAdvanced(true).build();
93 if (channelConfiguration.colorModeStateTopic != null) {
94 buildChannel(COLOR_MODE_CHANNEL_ID, ComponentChannelType.STRING, new TextValue(), "Current color mode",
96 .stateTopic(channelConfiguration.colorModeStateTopic, channelConfiguration.colorModeValueTemplate)
97 .inferOptimistic(channelConfiguration.optimistic).build();
100 if (channelConfiguration.colorTempStateTopic != null || channelConfiguration.colorTempCommandTopic != null) {
101 buildChannel(COLOR_TEMP_CHANNEL_ID, ComponentChannelType.NUMBER, colorTempValue, "Color Temperature", this)
102 .stateTopic(channelConfiguration.colorTempStateTopic, channelConfiguration.colorTempValueTemplate)
103 .commandTopic(channelConfiguration.colorTempCommandTopic, channelConfiguration.isRetain(),
104 channelConfiguration.getQos())
105 .inferOptimistic(channelConfiguration.optimistic).build();
108 if (effectValue != null
109 && (channelConfiguration.effectStateTopic != null || channelConfiguration.effectCommandTopic != null)) {
110 buildChannel(EFFECT_CHANNEL_ID, ComponentChannelType.STRING, Objects.requireNonNull(effectValue),
111 "Lighting Effect", this)
112 .stateTopic(channelConfiguration.effectStateTopic, channelConfiguration.effectValueTemplate)
113 .commandTopic(channelConfiguration.effectCommandTopic, channelConfiguration.isRetain(),
114 channelConfiguration.getQos())
115 .inferOptimistic(channelConfiguration.optimistic).build();
118 boolean hasColorChannel = false;
119 if (channelConfiguration.rgbStateTopic != null || channelConfiguration.rgbCommandTopic != null) {
120 hasColorChannel = true;
121 hiddenChannels.add(rgbChannel = buildChannel(RGB_CHANNEL_ID, ComponentChannelType.COLOR,
122 new ColorValue(ColorMode.RGB, null, null, 100), "RGB state", this)
123 .stateTopic(channelConfiguration.rgbStateTopic, channelConfiguration.rgbValueTemplate)
124 .commandTopic(channelConfiguration.rgbCommandTopic, channelConfiguration.isRetain(),
125 channelConfiguration.getQos())
129 if (channelConfiguration.rgbwStateTopic != null || channelConfiguration.rgbwCommandTopic != null) {
130 hasColorChannel = true;
132 .add(buildChannel(RGBW_CHANNEL_ID, ComponentChannelType.STRING, new TextValue(), "RGBW state", this)
133 .stateTopic(channelConfiguration.rgbwStateTopic, channelConfiguration.rgbwValueTemplate)
134 .commandTopic(channelConfiguration.rgbwCommandTopic, channelConfiguration.isRetain(),
135 channelConfiguration.getQos())
139 if (channelConfiguration.rgbwwStateTopic != null || channelConfiguration.rgbwwCommandTopic != null) {
140 hasColorChannel = true;
142 buildChannel(RGBWW_CHANNEL_ID, ComponentChannelType.STRING, new TextValue(), "RGBWW state", this)
143 .stateTopic(channelConfiguration.rgbwwStateTopic, channelConfiguration.rgbwwValueTemplate)
144 .commandTopic(channelConfiguration.rgbwwCommandTopic, channelConfiguration.isRetain(),
145 channelConfiguration.getQos())
149 if (channelConfiguration.xyStateTopic != null || channelConfiguration.xyCommandTopic != null) {
150 hasColorChannel = true;
151 hiddenChannels.add(xyChannel = buildChannel(XY_CHANNEL_ID, ComponentChannelType.COLOR,
152 new ColorValue(ColorMode.XYY, null, null, 100), "XY State", this)
153 .stateTopic(channelConfiguration.xyStateTopic, channelConfiguration.xyValueTemplate)
154 .commandTopic(channelConfiguration.xyCommandTopic, channelConfiguration.isRetain(),
155 channelConfiguration.getQos())
159 if (channelConfiguration.hsStateTopic != null || channelConfiguration.hsCommandTopic != null) {
160 hasColorChannel = true;
161 hiddenChannels.add(this.hsChannel = buildChannel(HS_CHANNEL_ID, ComponentChannelType.STRING,
162 new TextValue(), "Hue and Saturation", this)
163 .stateTopic(channelConfiguration.hsStateTopic, channelConfiguration.hsValueTemplate)
164 .commandTopic(channelConfiguration.hsCommandTopic, channelConfiguration.isRetain(),
165 channelConfiguration.getQos())
169 if (hasColorChannel) {
170 hiddenChannels.add(localOnOffChannel);
171 if (localBrightnessChannel != null) {
172 hiddenChannels.add(localBrightnessChannel);
174 colorChannel = buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this)
175 .commandTopic(DUMMY_TOPIC, channelConfiguration.isRetain(), channelConfiguration.getQos())
176 .commandFilter(this::handleColorCommand).withAutoUpdatePolicy(autoUpdatePolicy).build();
177 } else if (localBrightnessChannel != null) {
178 hiddenChannels.add(localOnOffChannel);
179 channels.put(BRIGHTNESS_CHANNEL_ID, localBrightnessChannel);
181 channels.put(ON_OFF_CHANNEL_ID, localOnOffChannel);
185 // all handle*Command methods return false if they've been handled,
186 // or true if default handling should continue
188 // The commandFilter for onOffChannel
189 private boolean handleRawOnOffCommand(Command command) {
190 // on_command_type of brightness is not allowed to send an actual on command
191 if (command.equals(OnOffType.ON) && channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_BRIGHTNESS)) {
192 // No prior state (or explicit off); set to 100%
193 if (brightnessValue.getChannelState() instanceof UnDefType
194 || brightnessValue.getChannelState().equals(PercentType.ZERO)) {
195 brightnessChannel.getState().publishValue(PercentType.HUNDRED);
197 brightnessChannel.getState().publishValue((Command) brightnessValue.getChannelState());
205 // The helper method the other commandFilters call
206 private boolean handleOnOffCommand(Command command) {
207 if (!handleRawOnOffCommand(command)) {
211 // OnOffType commands to go the regular command topic
212 if (command instanceof OnOffType) {
213 onOffChannel.getState().publishValue(command);
217 boolean needsOn = !onOffValue.getChannelState().equals(OnOffType.ON);
218 if (command.equals(PercentType.ZERO) || command.equals(HSBType.BLACK)) {
222 if (channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_FIRST)) {
223 onOffChannel.getState().publishValue(OnOffType.ON);
224 } else if (channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_LAST)) {
225 // TODO: schedule the ON publish for after this is sent
231 private boolean handleBrightnessCommand(Command command) {
232 // if it's OnOffType, it'll get handled by this; otherwise it'll return
233 // true and PercentType will be handled as normal
234 return handleOnOffCommand(command);
237 private boolean handleColorCommand(Command command) {
238 if (!handleOnOffCommand(command)) {
240 } else if (command instanceof HSBType color) {
241 if (channelConfiguration.hsCommandTopic != null) {
242 // If we don't have a brightness channel, something is probably busted
244 if (channelConfiguration.brightnessCommandTopic != null) {
245 brightnessChannel.getState().publishValue(color.getBrightness());
247 String hs = String.format("%d,%d", color.getHue().intValue(), color.getSaturation().intValue());
248 hsChannel.getState().publishValue(new StringType(hs));
249 } else if (channelConfiguration.rgbCommandTopic != null) {
250 rgbChannel.getState().publishValue(command);
251 // } else if (channelConfiguration.rgbwCommandTopic != null) {
253 // } else if (channelConfiguration.rgbwwCommandTopic != null) {
255 } else if (channelConfiguration.xyCommandTopic != null) {
256 PercentType[] xy = color.toXY();
257 // If we don't have a brightness channel, something is probably busted
259 if (channelConfiguration.brightnessCommandTopic != null) {
260 brightnessChannel.getState().publishValue(color.getBrightness());
262 String xyString = String.format("%f,%f", xy[0].doubleValue(), xy[1].doubleValue());
263 xyChannel.getState().publishValue(new StringType(xyString));
265 } else if (command instanceof PercentType brightness) {
266 if (channelConfiguration.brightnessCommandTopic != null) {
267 brightnessChannel.getState().publishValue(command);
269 // No brightness command topic?! must be RGB only
271 State color = colorValue.getChannelState();
272 if (color instanceof UnDefType) {
273 color = HSBType.WHITE;
275 HSBType existingColor = (HSBType) color;
276 HSBType newCommand = new HSBType(existingColor.getHue(), existingColor.getSaturation(), brightness);
278 handleColorCommand(newCommand);
285 public void updateChannelState(ChannelUID channel, State state) {
286 ChannelStateUpdateListener listener = this.channelStateUpdateListener;
287 String id = channel.getIdWithoutGroup();
288 ComponentChannel localBrightnessChannel = brightnessChannel;
289 ComponentChannel localColorChannel = colorChannel;
290 ChannelUID primaryChannelUID;
291 if (localColorChannel != null) {
292 primaryChannelUID = localColorChannel.getChannel().getUID();
293 } else if (localBrightnessChannel != null) {
294 primaryChannelUID = localBrightnessChannel.getChannel().getUID();
296 primaryChannelUID = onOffChannel.getChannel().getUID();
298 // on_off, brightness, and color might exist as a sole channel, which means
299 // they got renamed. they need to be compared against the actual UID of the
300 // channel. all the rest we can just check against the basic ID
301 if (channel.equals(onOffChannel.getChannel().getUID())) {
302 if (localColorChannel != null) {
303 HSBType newOnState = colorValue.getChannelState() instanceof HSBType newOnStateTmp ? newOnStateTmp
305 if (state.equals(OnOffType.ON)) {
306 colorValue.update(newOnState);
309 listener.updateChannelState(primaryChannelUID, state.equals(OnOffType.ON) ? newOnState : HSBType.BLACK);
310 } else if (brightnessChannel != null) {
311 listener.updateChannelState(primaryChannelUID,
312 state.equals(OnOffType.ON) ? brightnessValue.getChannelState() : PercentType.ZERO);
314 listener.updateChannelState(primaryChannelUID, state);
316 } else if (localBrightnessChannel != null && localBrightnessChannel.getChannel().getUID().equals(channel)) {
317 onOffValue.update(Objects.requireNonNull(state.as(OnOffType.class)));
318 if (localColorChannel != null) {
319 if (colorValue.getChannelState() instanceof HSBType hsb) {
320 colorValue.update(new HSBType(hsb.getHue(), hsb.getSaturation(),
321 (PercentType) brightnessValue.getChannelState()));
323 colorValue.update(new HSBType(DecimalType.ZERO, PercentType.ZERO,
324 (PercentType) brightnessValue.getChannelState()));
326 listener.updateChannelState(primaryChannelUID, colorValue.getChannelState());
328 listener.updateChannelState(primaryChannelUID, state);
330 } else if (id.equals(COLOR_TEMP_CHANNEL_ID) || channel.getIdWithoutGroup().equals(EFFECT_CHANNEL_ID)) {
331 // Real channels; pass through
332 listener.updateChannelState(channel, state);
333 } else if (id.equals(HS_CHANNEL_ID) || id.equals(XY_CHANNEL_ID)) {
334 if (brightnessValue.getChannelState() instanceof UnDefType) {
335 brightnessValue.update(PercentType.HUNDRED);
337 String[] split = state.toString().split(",");
338 if (split.length != 2) {
339 throw new IllegalArgumentException(state.toString() + " is not a valid string syntax");
341 float x = Float.parseFloat(split[0]);
342 float y = Float.parseFloat(split[1]);
343 PercentType brightness = (PercentType) brightnessValue.getChannelState();
344 if (channel.getIdWithoutGroup().equals(HS_CHANNEL_ID)) {
345 colorValue.update(new HSBType(new DecimalType(x), new PercentType(new BigDecimal(y)), brightness));
347 HSBType xyColor = HSBType.fromXY(x, y);
348 colorValue.update(new HSBType(xyColor.getHue(), xyColor.getSaturation(), brightness));
350 listener.updateChannelState(primaryChannelUID, colorValue.getChannelState());
351 } else if (id.equals(RGB_CHANNEL_ID)) {
352 colorValue.update((HSBType) state);
353 listener.updateChannelState(primaryChannelUID, colorValue.getChannelState());
355 // else rgbw channel, rgbww channel