]> git.basschouten.com Git - openhab-addons.git/blob
ee744a5031a7823ab942b8207cb7549249760793
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.mqtt.homeassistant.internal.component;
14
15 import java.math.BigDecimal;
16 import java.math.MathContext;
17 import java.util.List;
18 import java.util.Objects;
19
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;
41
42 import com.google.gson.JsonSyntaxException;
43 import com.google.gson.annotations.SerializedName;
44
45 /**
46  * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
47  *
48  * Specifically, the JSON schema. All channels are synthetic, and wrap the single internal raw
49  * state.
50  *
51  * @author Cody Cutrer - Initial contribution
52  */
53 @NonNullByDefault
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);
57
58     private final Logger logger = LoggerFactory.getLogger(JSONSchemaLight.class);
59
60     private @Nullable ComponentChannel colorTempChannel;
61
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;
66         }
67
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;
77     }
78
79     public JSONSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) {
80         super(builder, newStyleChannels);
81     }
82
83     @Override
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;
91             }
92
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();
98
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();
104
105                 }
106             }
107         }
108
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();
117         } else {
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();
121         }
122
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)
127                     .build();
128
129         }
130     }
131
132     private boolean handleEffectCommand(Command command) {
133         if (command instanceof StringType) {
134             JSONState json = new JSONState();
135             json.state = "ON";
136             json.effect = command.toString();
137             publishState(json);
138         }
139         return false;
140     }
141
142     @Override
143     protected void publishState(HSBType state) {
144         JSONState json = new JSONState();
145
146         logger.trace("Publishing new state {} of light {} to MQTT.", state, getName());
147         if (state.getBrightness().equals(PercentType.ZERO)) {
148             json.state = "OFF";
149         } else {
150             json.state = "ON";
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();
157             }
158
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);
173                 }
174             }
175         }
176
177         publishState(json);
178     }
179
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));
184     }
185
186     @Override
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)) {
193                 json.state = "OFF";
194             } else {
195                 json.state = "ON";
196                 if (channelConfiguration.brightness) {
197                     json.brightness = ((PercentType) command).toBigDecimal()
198                             .multiply(new BigDecimal(channelConfiguration.brightnessScale))
199                             .divide(new BigDecimal(100), MathContext.DECIMAL128).intValue();
200                 }
201             }
202         } else {
203             return super.handleCommand(command);
204         }
205
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));
209         return false;
210     }
211
212     private boolean handleColorTempCommand(Command command) {
213         JSONState json = new JSONState();
214
215         if (command instanceof DecimalType) {
216             command = new QuantityType<>(((DecimalType) command).toBigDecimal(), Units.MIRED);
217         }
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);
222                 return false;
223             }
224             json.state = "ON";
225             json.colorTemp = mireds.toBigDecimal().intValue();
226         } else {
227             return false;
228         }
229
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));
233         return false;
234     }
235
236     @Override
237     public void updateChannelState(ChannelUID channel, State state) {
238         ChannelStateUpdateListener listener = this.channelStateUpdateListener;
239         ComponentChannel localBrightnessChannel = brightnessChannel;
240         ComponentChannel localColorChannel = colorChannel;
241
242         @Nullable
243         JSONState jsonState;
244         try {
245             jsonState = getGson().fromJson(state.toString(), JSONState.class);
246
247             if (jsonState == null) {
248                 logger.warn("JSON light state for '{}' is empty.", getHaID());
249                 return;
250             }
251         } catch (JsonSyntaxException e) {
252             logger.warn("Cannot parse JSON light state '{}' for '{}'.", state, getHaID());
253             return;
254         }
255
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());
260             } else {
261                 listener.updateChannelState(buildChannelUID(EFFECT_CHANNEL_ID), UnDefType.NULL);
262             }
263         }
264
265         boolean off = false;
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)));
272                 }
273                 if (colorValue.getChannelState() instanceof UnDefType) {
274                     colorValue.update(Objects.requireNonNull(onOffState.as(PercentType.class)));
275                 }
276             }
277         }
278
279         PercentType brightness;
280         if (off) {
281             brightness = PercentType.ZERO;
282         } else if (brightnessValue.getChannelState() instanceof PercentType percentValue) {
283             brightness = percentValue;
284         } else {
285             brightness = PercentType.HUNDRED;
286         }
287
288         if (jsonState.brightness != null) {
289             if (!off) {
290                 brightness = (PercentType) brightnessValue
291                         .parseMessage(new DecimalType(Objects.requireNonNull(jsonState.brightness)));
292             }
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));
297             } else {
298                 colorValue.update(new HSBType(DecimalType.ZERO, PercentType.ZERO, brightness));
299             }
300         }
301
302         try {
303             LightColorMode localColorMode = jsonState.colorMode;
304             if (localColorMode != null) {
305                 colorModeValue.update(new StringType(localColorMode.serializedName()));
306
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());
312                         } else {
313                             colorTempValue
314                                     .update(new QuantityType(Objects.requireNonNull(jsonState.colorTemp), Units.MIRED));
315                             listener.updateChannelState(buildChannelUID(COLOR_TEMP_CHANNEL_ID),
316                                     colorTempValue.getChannelState());
317
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
320                             try {
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());
328                             }
329                         }
330                         break;
331                     case COLOR_MODE_XY:
332                         if (jsonState.color == null || jsonState.color.x == null || jsonState.color.y == null) {
333                             logger.warn("Incomplete xy color received for {}", getHaID());
334                         } else {
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());
344                             }
345                         }
346                         break;
347                     case COLOR_MODE_HS:
348                         if (jsonState.color == null || jsonState.color.h == null || jsonState.color.s == null) {
349                             logger.warn("Incomplete hs color received for {}", getHaID());
350                         } else {
351                             colorValue.update(new HSBType(new DecimalType(Objects.requireNonNull(jsonState.color.h)),
352                                     new PercentType(Objects.requireNonNull(jsonState.color.s)), brightness));
353                         }
354                         break;
355                     case COLOR_MODE_RGB:
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());
361                         } else {
362                             colorValue.update(ColorUtil
363                                     .rgbToHsb(new int[] { jsonState.color.r, jsonState.color.g, jsonState.color.b }));
364                         }
365                         break;
366                     default:
367                         break;
368                 }
369
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());
379                 }
380
381             } else {
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());
388
389                     colorModeValue.update(new StringType(LightColorMode.COLOR_MODE_COLOR_TEMP.serializedName()));
390                 }
391
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()));
406                     }
407
408                 }
409             }
410         } catch (IllegalArgumentException e) {
411             logger.warn("Invalid color value for {}", getHaID());
412         }
413
414         listener.updateChannelState(buildChannelUID(COLOR_MODE_CHANNEL_ID), colorModeValue.getChannelState());
415
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());
421         } else {
422             listener.updateChannelState(onOffChannel.getChannel().getUID(), onOffValue.getChannelState());
423         }
424     }
425 }