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.math.MathContext;
17 import java.util.List;
18 import java.util.Objects;
20 import org.eclipse.jdt.annotation.NonNullByDefault;
21 import org.eclipse.jdt.annotation.Nullable;
22 import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
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.QuantityType;
31 import org.openhab.core.library.types.StringType;
32 import org.openhab.core.library.unit.Units;
33 import org.openhab.core.thing.ChannelUID;
34 import org.openhab.core.thing.type.AutoUpdatePolicy;
35 import org.openhab.core.types.Command;
36 import org.openhab.core.types.State;
37 import org.openhab.core.types.UnDefType;
38 import org.openhab.core.util.ColorUtil;
39 import org.slf4j.Logger;
40 import org.slf4j.LoggerFactory;
42 import com.google.gson.JsonSyntaxException;
43 import com.google.gson.annotations.SerializedName;
46 * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
48 * Specifically, the JSON schema. All channels are synthetic, and wrap the single internal raw
51 * @author Cody Cutrer - Initial contribution
54 public class JSONSchemaLight extends AbstractRawSchemaLight {
55 private static final BigDecimal SCALE_FACTOR = new BigDecimal("2.55"); // string to not lose precision
56 private static final BigDecimal BIG_DECIMAL_HUNDRED = new BigDecimal(100);
58 private final Logger logger = LoggerFactory.getLogger(JSONSchemaLight.class);
60 private @Nullable ComponentChannel colorTempChannel;
62 private static class JSONState {
63 protected static class Color {
64 protected @Nullable Integer r, g, b, c, w;
65 protected @Nullable BigDecimal x, y, h, s;
68 protected @Nullable String state;
69 protected @Nullable Integer brightness;
70 @SerializedName("color_mode")
71 protected @Nullable LightColorMode colorMode;
72 @SerializedName("color_temp")
73 protected @Nullable Integer colorTemp;
74 protected @Nullable Color color;
75 protected @Nullable String effect;
76 protected @Nullable Integer transition;
79 public JSONSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) {
80 super(builder, newStyleChannels);
84 protected void buildChannels() {
85 boolean hasColorChannel = false;
86 AutoUpdatePolicy autoUpdatePolicy = optimistic ? AutoUpdatePolicy.RECOMMEND : null;
87 List<LightColorMode> supportedColorModes = channelConfiguration.supportedColorModes;
88 if (supportedColorModes != null) {
89 if (LightColorMode.hasColorChannel(supportedColorModes)) {
90 hasColorChannel = true;
93 if (supportedColorModes.contains(LightColorMode.COLOR_MODE_COLOR_TEMP)) {
94 colorTempChannel = buildChannel(COLOR_TEMP_CHANNEL_ID, ComponentChannelType.NUMBER, colorTempValue,
95 "Color Temperature", this).commandTopic(DUMMY_TOPIC, true, 1)
96 .commandFilter(command -> handleColorTempCommand(command))
97 .withAutoUpdatePolicy(autoUpdatePolicy).build();
99 if (hasColorChannel) {
100 colorModeValue = new TextValue(
101 supportedColorModes.stream().map(LightColorMode::serializedName).toArray(String[]::new));
102 buildChannel(COLOR_MODE_CHANNEL_ID, ComponentChannelType.STRING, colorModeValue, "Color Mode", this)
103 .withAutoUpdatePolicy(autoUpdatePolicy).isAdvanced(true).build();
109 if (hasColorChannel) {
110 colorChannel = buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this)
111 .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand)
112 .withAutoUpdatePolicy(autoUpdatePolicy).build();
113 } else if (channelConfiguration.brightness) {
114 brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, ComponentChannelType.DIMMER, brightnessValue,
115 "Brightness", this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand)
116 .withAutoUpdatePolicy(autoUpdatePolicy).build();
118 onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue, "On/Off State",
119 this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand)
120 .withAutoUpdatePolicy(autoUpdatePolicy).build();
123 if (effectValue != null) {
124 buildChannel(EFFECT_CHANNEL_ID, ComponentChannelType.STRING, Objects.requireNonNull(effectValue),
125 "Lighting Effect", this).commandTopic(DUMMY_TOPIC, true, 1)
126 .commandFilter(command -> handleEffectCommand(command)).withAutoUpdatePolicy(autoUpdatePolicy)
132 private boolean handleEffectCommand(Command command) {
133 if (command instanceof StringType) {
134 JSONState json = new JSONState();
136 json.effect = command.toString();
143 protected void publishState(HSBType state) {
144 JSONState json = new JSONState();
146 logger.trace("Publishing new state {} of light {} to MQTT.", state, getName());
147 if (state.getBrightness().equals(PercentType.ZERO)) {
151 if (channelConfiguration.brightness || (channelConfiguration.supportedColorModes != null
152 && (channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_HS)
153 || channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_XY)))) {
154 json.brightness = state.getBrightness().toBigDecimal()
155 .multiply(new BigDecimal(channelConfiguration.brightnessScale))
156 .divide(new BigDecimal(100), MathContext.DECIMAL128).intValue();
159 if (colorChannel != null) {
160 json.color = new JSONState.Color();
161 if (channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_HS)) {
162 json.color.h = state.getHue().toBigDecimal();
163 json.color.s = state.getSaturation().toBigDecimal().divide(BIG_DECIMAL_HUNDRED);
164 } else if (LightColorMode.hasRGB(Objects.requireNonNull(channelConfiguration.supportedColorModes))) {
165 var rgb = state.toRGB();
166 json.color.r = rgb[0].toBigDecimal().multiply(SCALE_FACTOR).intValue();
167 json.color.g = rgb[1].toBigDecimal().multiply(SCALE_FACTOR).intValue();
168 json.color.b = rgb[2].toBigDecimal().multiply(SCALE_FACTOR).intValue();
169 } else { // if (channelConfiguration.supportedColorModes.contains(COLOR_MODE_XY))
170 var xy = state.toXY();
171 json.color.x = xy[0].toBigDecimal().divide(BIG_DECIMAL_HUNDRED);
172 json.color.y = xy[1].toBigDecimal().divide(BIG_DECIMAL_HUNDRED);
180 private void publishState(JSONState json) {
181 String command = getGson().toJson(json);
182 logger.debug("Publishing new state '{}' of light {} to MQTT.", command, getName());
183 rawChannel.getState().publishValue(new StringType(command));
187 protected boolean handleCommand(Command command) {
188 JSONState json = new JSONState();
189 if (command.getClass().equals(OnOffType.class)) {
190 json.state = command.toString();
191 } else if (command.getClass().equals(PercentType.class)) {
192 if (command.equals(PercentType.ZERO)) {
196 if (channelConfiguration.brightness) {
197 json.brightness = ((PercentType) command).toBigDecimal()
198 .multiply(new BigDecimal(channelConfiguration.brightnessScale))
199 .divide(new BigDecimal(100), MathContext.DECIMAL128).intValue();
203 return super.handleCommand(command);
206 String jsonCommand = getGson().toJson(json);
207 logger.debug("Publishing new state '{}' of light {} to MQTT.", jsonCommand, getName());
208 rawChannel.getState().publishValue(new StringType(jsonCommand));
212 private boolean handleColorTempCommand(Command command) {
213 JSONState json = new JSONState();
215 if (command instanceof DecimalType) {
216 command = new QuantityType<>(((DecimalType) command).toBigDecimal(), Units.MIRED);
218 if (command instanceof QuantityType) {
219 QuantityType<?> mireds = ((QuantityType<?>) command).toInvertibleUnit(Units.MIRED);
220 if (mireds == null) {
221 logger.warn("Unable to convert {} to mireds", command);
225 json.colorTemp = mireds.toBigDecimal().intValue();
230 String jsonCommand = getGson().toJson(json);
231 logger.debug("Publishing new state '{}' of light {} to MQTT.", jsonCommand, getName());
232 rawChannel.getState().publishValue(new StringType(jsonCommand));
237 public void updateChannelState(ChannelUID channel, State state) {
238 ChannelStateUpdateListener listener = this.channelStateUpdateListener;
239 ComponentChannel localBrightnessChannel = brightnessChannel;
240 ComponentChannel localColorChannel = colorChannel;
245 jsonState = getGson().fromJson(state.toString(), JSONState.class);
247 if (jsonState == null) {
248 logger.warn("JSON light state for '{}' is empty.", getHaID());
251 } catch (JsonSyntaxException e) {
252 logger.warn("Cannot parse JSON light state '{}' for '{}'.", state, getHaID());
256 if (effectValue != null) {
257 if (jsonState.effect != null) {
258 effectValue.update(new StringType(jsonState.effect));
259 listener.updateChannelState(buildChannelUID(EFFECT_CHANNEL_ID), effectValue.getChannelState());
261 listener.updateChannelState(buildChannelUID(EFFECT_CHANNEL_ID), UnDefType.NULL);
266 if (jsonState.state != null) {
267 onOffValue.update((State) onOffValue.parseMessage(new StringType(jsonState.state)));
268 off = onOffValue.getChannelState().equals(OnOffType.OFF);
269 if (onOffValue.getChannelState() instanceof OnOffType onOffState) {
270 if (brightnessValue.getChannelState() instanceof UnDefType) {
271 brightnessValue.update(Objects.requireNonNull(onOffState.as(PercentType.class)));
273 if (colorValue.getChannelState() instanceof UnDefType) {
274 colorValue.update(Objects.requireNonNull(onOffState.as(PercentType.class)));
279 PercentType brightness;
281 brightness = PercentType.ZERO;
282 } else if (brightnessValue.getChannelState() instanceof PercentType percentValue) {
283 brightness = percentValue;
285 brightness = PercentType.HUNDRED;
288 if (jsonState.brightness != null) {
290 brightness = (PercentType) brightnessValue
291 .parseMessage(new DecimalType(Objects.requireNonNull(jsonState.brightness)));
293 brightnessValue.update(brightness);
294 if (colorValue.getChannelState() instanceof HSBType) {
295 HSBType color = (HSBType) colorValue.getChannelState();
296 colorValue.update(new HSBType(color.getHue(), color.getSaturation(), brightness));
298 colorValue.update(new HSBType(DecimalType.ZERO, PercentType.ZERO, brightness));
303 LightColorMode localColorMode = jsonState.colorMode;
304 if (localColorMode != null) {
305 colorModeValue.update(new StringType(localColorMode.serializedName()));
307 switch (localColorMode) {
308 case COLOR_MODE_COLOR_TEMP:
309 Integer localColorTemp = jsonState.colorTemp;
310 if (localColorTemp == null) {
311 logger.warn("Incomplete color_temp received for {}", getHaID());
314 .update(new QuantityType(Objects.requireNonNull(jsonState.colorTemp), Units.MIRED));
315 listener.updateChannelState(buildChannelUID(COLOR_TEMP_CHANNEL_ID),
316 colorTempValue.getChannelState());
318 // Populate the color channel (if there is one) to match the color temperature.
319 // First convert color temp to XY, then to HSB, then add in the brightness
321 final double[] xy = ColorUtil.kelvinToXY(1000000d / localColorTemp);
322 HSBType color = ColorUtil.xyToHsb(xy);
323 color = new HSBType(color.getHue(), color.getSaturation(), brightness);
324 colorValue.update(color);
325 } catch (IndexOutOfBoundsException e) {
326 logger.warn("Color temperature {} cannot be converted to a color for {}",
327 localColorTemp, getHaID());
332 if (jsonState.color == null || jsonState.color.x == null || jsonState.color.y == null) {
333 logger.warn("Incomplete xy color received for {}", getHaID());
335 final double[] xy = new double[] { jsonState.color.x.doubleValue(),
336 jsonState.color.y.doubleValue() };
337 HSBType newColor = ColorUtil.xyToHsb(xy);
338 colorValue.update(new HSBType(newColor.getHue(), newColor.getSaturation(), brightness));
339 if (colorTempChannel != null) {
340 double kelvin = ColorUtil.xyToKelvin(xy);
341 colorTempValue.update(new QuantityType(kelvin, Units.KELVIN));
342 listener.updateChannelState(buildChannelUID(COLOR_TEMP_CHANNEL_ID),
343 colorTempValue.getChannelState());
348 if (jsonState.color == null || jsonState.color.h == null || jsonState.color.s == null) {
349 logger.warn("Incomplete hs color received for {}", getHaID());
351 colorValue.update(new HSBType(new DecimalType(Objects.requireNonNull(jsonState.color.h)),
352 new PercentType(Objects.requireNonNull(jsonState.color.s)), brightness));
356 case COLOR_MODE_RGBW:
357 case COLOR_MODE_RGBWW:
358 if (jsonState.color == null || jsonState.color.r == null || jsonState.color.g == null
359 || jsonState.color.b == null) {
360 logger.warn("Incomplete rgb color received for {}", getHaID());
362 colorValue.update(ColorUtil
363 .rgbToHsb(new int[] { jsonState.color.r, jsonState.color.g, jsonState.color.b }));
370 // calculate the CCT of the color (xy was special cased above, to do a more direct calculation)
371 if (!localColorMode.equals(LightColorMode.COLOR_MODE_COLOR_TEMP)
372 && !localColorMode.equals(LightColorMode.COLOR_MODE_XY) && localColorChannel != null
373 && colorTempChannel != null && colorValue.getChannelState() instanceof HSBType colorState) {
374 final double[] xy = ColorUtil.hsbToXY(colorState);
375 double kelvin = ColorUtil.xyToKelvin(new double[] { xy[0], xy[1] });
376 colorTempValue.update(new QuantityType(kelvin, Units.KELVIN));
377 listener.updateChannelState(buildChannelUID(COLOR_TEMP_CHANNEL_ID),
378 colorTempValue.getChannelState());
382 // "deprecated" color mode handling - color mode not specified, so we just accept what we can. See
383 // https://github.com/home-assistant/core/blob/4f965f0eca09f0d12ae1c98c6786054063a36b44/homeassistant/components/mqtt/light/schema_json.py#L258
384 if (jsonState.colorTemp != null) {
385 colorTempValue.update(new QuantityType(Objects.requireNonNull(jsonState.colorTemp), Units.MIRED));
386 listener.updateChannelState(buildChannelUID(COLOR_TEMP_CHANNEL_ID),
387 colorTempValue.getChannelState());
389 colorModeValue.update(new StringType(LightColorMode.COLOR_MODE_COLOR_TEMP.serializedName()));
392 if (jsonState.color != null) {
393 if (jsonState.color.h != null && jsonState.color.s != null) {
394 colorValue.update(new HSBType(new DecimalType(Objects.requireNonNull(jsonState.color.h)),
395 new PercentType(Objects.requireNonNull(jsonState.color.s)), brightness));
396 colorModeValue.update(new StringType(LightColorMode.COLOR_MODE_HS.serializedName()));
397 } else if (jsonState.color.x != null && jsonState.color.y != null) {
398 HSBType newColor = ColorUtil.xyToHsb(
399 new double[] { jsonState.color.x.doubleValue(), jsonState.color.y.doubleValue() });
400 colorValue.update(new HSBType(newColor.getHue(), newColor.getSaturation(), brightness));
401 colorModeValue.update(new StringType(LightColorMode.COLOR_MODE_XY.serializedName()));
402 } else if (jsonState.color.r != null && jsonState.color.g != null && jsonState.color.b != null) {
403 colorValue.update(ColorUtil
404 .rgbToHsb(new int[] { jsonState.color.r, jsonState.color.g, jsonState.color.b }));
405 colorModeValue.update(new StringType(LightColorMode.COLOR_MODE_RGB.serializedName()));
410 } catch (IllegalArgumentException e) {
411 logger.warn("Invalid color value for {}", getHaID());
414 listener.updateChannelState(buildChannelUID(COLOR_MODE_CHANNEL_ID), colorModeValue.getChannelState());
416 if (localColorChannel != null) {
417 listener.updateChannelState(localColorChannel.getChannel().getUID(), colorValue.getChannelState());
418 } else if (localBrightnessChannel != null) {
419 listener.updateChannelState(localBrightnessChannel.getChannel().getUID(),
420 brightnessValue.getChannelState());
422 listener.updateChannelState(onOffChannel.getChannel().getUID(), onOffValue.getChannelState());