import org.openhab.core.thing.type.ChannelGroupTypeBuilder;
import org.openhab.core.thing.type.ChannelGroupTypeUID;
+import com.google.gson.Gson;
+
/**
* A HomeAssistant component is comparable to a channel group.
* It has a name and consists of multiple channels.
public boolean isEnabledByDefault() {
return channelConfiguration.isEnabledByDefault();
}
+
+ public Gson getGson() {
+ return componentConfiguration.getGson();
+ }
}
--- /dev/null
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mqtt.homeassistant.internal.component;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.types.Command;
+
+/**
+ * A base class for common elements between JSON schema and template schema lights.
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+abstract class AbstractRawSchemaLight extends Light {
+ protected static final String RAW_CHANNEL_ID = "raw";
+
+ protected ComponentChannel rawChannel;
+
+ public AbstractRawSchemaLight(ComponentFactory.ComponentConfiguration builder) {
+ super(builder);
+ hiddenChannels.add(rawChannel = buildChannel(RAW_CHANNEL_ID, new TextValue(), "Raw state", this)
+ .stateTopic(channelConfiguration.stateTopic).commandTopic(channelConfiguration.commandTopic,
+ channelConfiguration.isRetain(), channelConfiguration.getQos())
+ .build(false));
+ }
+
+ protected boolean handleCommand(Command command) {
+ HSBType newState;
+ if (colorValue.getChannelState() instanceof HSBType) {
+ newState = (HSBType) colorValue.getChannelState();
+ } else {
+ newState = HSBType.WHITE;
+ }
+
+ if (command.equals(PercentType.ZERO) || command.equals(OnOffType.OFF)) {
+ newState = HSBType.BLACK;
+ } else if (command.equals(OnOffType.ON)) {
+ if (newState.getBrightness().equals(PercentType.ZERO)) {
+ newState = new HSBType(newState.getHue(), newState.getSaturation(), PercentType.HUNDRED);
+ }
+ } else if (command instanceof HSBType) {
+ newState = (HSBType) command;
+ } else if (command instanceof PercentType) {
+ newState = new HSBType(newState.getHue(), newState.getSaturation(), (PercentType) command);
+ } else {
+ return false;
+ }
+
+ publishState(newState);
+ return false;
+ }
+
+ protected abstract void publishState(HSBType state);
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mqtt.homeassistant.internal.component;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.util.List;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
+import org.openhab.binding.mqtt.homeassistant.internal.exception.UnsupportedComponentException;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
+ *
+ * Specifically, the JSON schema. All channels are synthetic, and wrap the single internal raw
+ * state.
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+public class JSONSchemaLight extends AbstractRawSchemaLight {
+ private static final BigDecimal SCALE_FACTOR = new BigDecimal("2.55"); // string to not lose precision
+
+ private final Logger logger = LoggerFactory.getLogger(JSONSchemaLight.class);
+
+ private static class JSONState {
+ protected static class Color {
+ protected @Nullable Integer r, g, b, c, w;
+ protected @Nullable BigDecimal x, y, h, s;
+ }
+
+ protected @Nullable String state;
+ protected @Nullable Integer brightness;
+ @SerializedName("color_mode")
+ protected @Nullable LightColorMode colorMode;
+ @SerializedName("color_temp")
+ protected @Nullable Integer colorTemp;
+ protected @Nullable Color color;
+ protected @Nullable String effect;
+ protected @Nullable Integer transition;
+ }
+
+ public JSONSchemaLight(ComponentFactory.ComponentConfiguration builder) {
+ super(builder);
+ }
+
+ @Override
+ protected void buildChannels() {
+ if (channelConfiguration.colorMode) {
+ List<LightColorMode> supportedColorModes = channelConfiguration.supportedColorModes;
+ if (supportedColorModes == null || channelConfiguration.supportedColorModes.isEmpty()) {
+ throw new UnsupportedComponentException("JSON schema light with color modes '" + getHaID()
+ + "' does not define supported_color_modes!");
+ }
+
+ if (LightColorMode.hasColorChannel(supportedColorModes)) {
+ hasColorChannel = true;
+ }
+ }
+
+ if (hasColorChannel) {
+ buildChannel(COLOR_CHANNEL_ID, colorValue, "Color", this).commandTopic(DUMMY_TOPIC, true, 1)
+ .commandFilter(this::handleCommand).build();
+ } else if (channelConfiguration.brightness) {
+ brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, brightnessValue, "Brightness", this)
+ .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand).build();
+ } else {
+ onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, onOffValue, "On/Off State", this)
+ .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand).build();
+ }
+ }
+
+ @Override
+ protected void publishState(HSBType state) {
+ JSONState json = new JSONState();
+
+ logger.trace("Publishing new state {} of light {} to MQTT.", state, getName());
+ if (state.getBrightness().equals(PercentType.ZERO)) {
+ json.state = "OFF";
+ } else {
+ json.state = "ON";
+ if (channelConfiguration.brightness || (channelConfiguration.supportedColorModes != null
+ && (channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_HS)
+ || channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_XY)))) {
+ json.brightness = state.getBrightness().toBigDecimal()
+ .multiply(new BigDecimal(channelConfiguration.brightnessScale))
+ .divide(new BigDecimal(100), MathContext.DECIMAL128).intValue();
+ }
+
+ if (hasColorChannel) {
+ json.color = new JSONState.Color();
+ if (channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_HS)) {
+ json.color.h = state.getHue().toBigDecimal();
+ json.color.s = state.getSaturation().toBigDecimal();
+ } else if (LightColorMode.hasRGB(Objects.requireNonNull(channelConfiguration.supportedColorModes))) {
+ var rgb = state.toRGB();
+ json.color.r = rgb[0].toBigDecimal().multiply(SCALE_FACTOR).intValue();
+ json.color.g = rgb[1].toBigDecimal().multiply(SCALE_FACTOR).intValue();
+ json.color.b = rgb[2].toBigDecimal().multiply(SCALE_FACTOR).intValue();
+ } else { // if (channelConfiguration.supportedColorModes.contains(COLOR_MODE_XY))
+ var xy = state.toXY();
+ json.color.x = xy[0].toBigDecimal();
+ json.color.y = xy[1].toBigDecimal();
+ }
+ }
+ }
+
+ String command = getGson().toJson(json);
+ logger.debug("Publishing new state '{}' of light {} to MQTT.", command, getName());
+ rawChannel.getState().publishValue(new StringType(command));
+ }
+
+ protected boolean handleCommand(Command command) {
+ JSONState json = new JSONState();
+ if (command.getClass().equals(OnOffType.class)) {
+ json.state = command.toString();
+ } else if (command.getClass().equals(PercentType.class)) {
+ if (command.equals(PercentType.ZERO)) {
+ json.state = "OFF";
+ } else {
+ json.state = "ON";
+ if (channelConfiguration.brightness) {
+ json.brightness = ((PercentType) command).toBigDecimal()
+ .multiply(new BigDecimal(channelConfiguration.brightnessScale))
+ .divide(new BigDecimal(100), MathContext.DECIMAL128).intValue();
+ }
+ }
+ } else {
+ return super.handleCommand(command);
+ }
+
+ String jsonCommand = getGson().toJson(json);
+ logger.debug("Publishing new state '{}' of light {} to MQTT.", jsonCommand, getName());
+ rawChannel.getState().publishValue(new StringType(jsonCommand));
+ return false;
+ }
+
+ @Override
+ public void updateChannelState(ChannelUID channel, State state) {
+ ChannelStateUpdateListener listener = this.channelStateUpdateListener;
+
+ @Nullable
+ JSONState jsonState;
+ try {
+ jsonState = getGson().fromJson(state.toString(), JSONState.class);
+
+ if (jsonState == null) {
+ logger.warn("JSON light state for '{}' is empty.", getHaID());
+ return;
+ }
+ } catch (JsonSyntaxException e) {
+ logger.warn("Cannot parse JSON light state '{}' for '{}'.", state, getHaID());
+ return;
+ }
+
+ if (jsonState.state != null) {
+ onOffValue.update(new StringType(jsonState.state));
+ if (brightnessValue.getChannelState() instanceof UnDefType) {
+ brightnessValue.update((OnOffType) onOffValue.getChannelState());
+ }
+ if (colorValue.getChannelState() instanceof UnDefType) {
+ colorValue.update((OnOffType) onOffValue.getChannelState());
+ }
+ }
+
+ if (jsonState.brightness != null) {
+ brightnessValue.update(new DecimalType(Objects.requireNonNull(jsonState.brightness)));
+ if (colorValue.getChannelState() instanceof HSBType) {
+ HSBType color = (HSBType) colorValue.getChannelState();
+ colorValue.update(new HSBType(color.getHue(), color.getSaturation(),
+ (PercentType) brightnessValue.getChannelState()));
+ } else {
+ colorValue.update(new HSBType(DecimalType.ZERO, PercentType.ZERO,
+ (PercentType) brightnessValue.getChannelState()));
+ }
+ }
+
+ if (jsonState.color != null) {
+ PercentType brightness = brightnessValue.getChannelState() instanceof PercentType
+ ? (PercentType) brightnessValue.getChannelState()
+ : PercentType.HUNDRED;
+ // This corresponds to "deprecated" color mode handling, since we're not checking which color
+ // mode is currently active.
+ // HS is highest priority, then XY, then RGB
+ // See
+ // https://github.com/home-assistant/core/blob/4f965f0eca09f0d12ae1c98c6786054063a36b44/homeassistant/components/mqtt/light/schema_json.py#L258
+ if (jsonState.color.h != null && jsonState.color.s != null) {
+ colorValue.update(new HSBType(new DecimalType(Objects.requireNonNull(jsonState.color.h)),
+ new PercentType(Objects.requireNonNull(jsonState.color.s)), brightness));
+ } else if (jsonState.color.x != null && jsonState.color.y != null) {
+ HSBType newColor = HSBType.fromXY(jsonState.color.x.floatValue(), jsonState.color.y.floatValue());
+ colorValue.update(new HSBType(newColor.getHue(), newColor.getSaturation(), brightness));
+ } else if (jsonState.color.r != null && jsonState.color.g != null && jsonState.color.b != null) {
+ colorValue.update(HSBType.fromRGB(jsonState.color.r, jsonState.color.g, jsonState.color.b));
+ }
+ }
+
+ if (hasColorChannel) {
+ listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID), colorValue.getChannelState());
+ } else if (brightnessChannel != null) {
+ listener.updateChannelState(new ChannelUID(getGroupUID(), BRIGHTNESS_CHANNEL_ID),
+ brightnessValue.getChannelState());
+ } else {
+ listener.updateChannelState(new ChannelUID(getGroupUID(), ON_OFF_CHANNEL_ID), onOffValue.getChannelState());
+ }
+ }
+}
@SerializedName("color_mode")
protected boolean colorMode = false; // JSON schema only
@SerializedName("supported_color_modes")
- protected @Nullable List<String> supportedColorModes; // JSON schema only
+ protected @Nullable List<LightColorMode> supportedColorModes; // JSON schema only
// Defines when on the payload_on is sent. Using last (the default) will send
// any style (brightness, color, etc)
// topics first and then a payload_on to the command_topic. Using first will
switch (schema) {
case DEFAULT_SCHEMA:
return new DefaultSchemaLight(builder);
+ case JSON_SCHEMA:
+ return new JSONSchemaLight(builder);
default:
throw new UnsupportedComponentException(
"Component '" + builder.getHaID() + "' of schema '" + schema + "' is not supported!");
--- /dev/null
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mqtt.homeassistant.internal.component;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The types of color modes a JSONSchemaLight can support.
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+public enum LightColorMode {
+ @SerializedName("onoff")
+ COLOR_MODE_ONOFF,
+ @SerializedName("brightness")
+ COLOR_MODE_BRIGHTNESS,
+ @SerializedName("color_temp")
+ COLOR_MODE_COLOR_TEMP,
+ @SerializedName("hs")
+ COLOR_MODE_HS,
+ @SerializedName("xy")
+ COLOR_MODE_XY,
+ @SerializedName("rgb")
+ COLOR_MODE_RGB,
+ @SerializedName("rgbw")
+ COLOR_MODE_RGBW,
+ @SerializedName("rgbww")
+ COLOR_MODE_RGBWW,
+ @SerializedName("white")
+ COLOR_MODE_WHITE;
+
+ public static final List<LightColorMode> WITH_RGB = List.of(COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW);
+ public static final List<LightColorMode> WITH_COLOR_CHANNEL = List.of(COLOR_MODE_HS, COLOR_MODE_RGB,
+ COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODE_XY);
+
+ /**
+ * Determines if the list of supported modes includes any that should generate an openHAB Color channel
+ */
+ public static boolean hasColorChannel(List<LightColorMode> supportedColorModes) {
+ return WITH_COLOR_CHANNEL.stream().anyMatch(cm -> supportedColorModes.contains(cm));
+ }
+
+ /**
+ * Determins if the list of supported modes includes any that have RGB components
+ */
+ public static boolean hasRGB(List<LightColorMode> supportedColorModes) {
+ return WITH_RGB.stream().anyMatch(cm -> supportedColorModes.contains(cm));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mqtt.homeassistant.internal.component;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.ColorValue;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.binding.mqtt.generic.values.PercentageValue;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+
+/**
+ * Tests for {@link Light} conforming to the JSON schema
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+public class JSONSchemaLightTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "light/0x0000000000000000_light_zigbee2mqtt";
+
+ @Test
+ public void testRgb() throws InterruptedException {
+ // @formatter:off
+ var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Lights inc\", " +
+ " \"model\": \"light v1\", " +
+ " \"name\": \"Light\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"light\", " +
+ " \"schema\": \"json\", " +
+ " \"state_topic\": \"zigbee2mqtt/light/state\", " +
+ " \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+ " \"brightness\": true, " +
+ " \"color_mode\": true, " +
+ " \"supported_color_modes\": [\"onoff\", \"brightness\", \"rgb\"]" +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("light"));
+
+ assertChannel(component, Light.COLOR_CHANNEL_ID, "", "dummy", "Color", ColorValue.class);
+
+ publishMessage("zigbee2mqtt/light/state", "{ \"state\": \"ON\" }");
+ assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE);
+ publishMessage("zigbee2mqtt/light/state", "{ \"color\": {\"r\": 10, \"g\": 20, \"b\": 30 } }");
+ assertState(component, Light.COLOR_CHANNEL_ID, HSBType.fromRGB(10, 20, 30));
+ publishMessage("zigbee2mqtt/light/state", "{ \"brightness\": 255 }");
+ assertState(component, Light.COLOR_CHANNEL_ID, new HSBType("210,66,100"));
+
+ sendCommand(component, Light.COLOR_CHANNEL_ID, HSBType.BLUE);
+ assertPublished("zigbee2mqtt/light/set/state",
+ "{\"state\":\"ON\",\"brightness\":255,\"color\":{\"r\":0,\"g\":0,\"b\":255}}");
+
+ // OnOff commands should route to the correct topic
+ sendCommand(component, Light.COLOR_CHANNEL_ID, OnOffType.OFF);
+ assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"OFF\"}");
+
+ sendCommand(component, Light.COLOR_CHANNEL_ID, OnOffType.ON);
+ assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"ON\"}");
+
+ sendCommand(component, Light.COLOR_CHANNEL_ID, new PercentType(50));
+ assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"ON\",\"brightness\":127}");
+ }
+
+ @Test
+ public void testBrightnessAndOnOff() throws InterruptedException {
+ // @formatter:off
+ var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"name\": \"light\", " +
+ " \"schema\": \"json\", " +
+ " \"state_topic\": \"zigbee2mqtt/light/state\", " +
+ " \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+ " \"brightness\": true" +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("light"));
+
+ assertChannel(component, Light.BRIGHTNESS_CHANNEL_ID, "", "dummy", "Brightness", PercentageValue.class);
+
+ publishMessage("zigbee2mqtt/light/state", "{ \"state\": \"ON\", \"brightness\": 128 }");
+ assertState(component, Light.BRIGHTNESS_CHANNEL_ID,
+ new PercentType(new BigDecimal(128 * 100).divide(new BigDecimal(255), MathContext.DECIMAL128)));
+
+ sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, PercentType.HUNDRED);
+ assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"ON\",\"brightness\":255}");
+
+ sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, OnOffType.OFF);
+ assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"OFF\"}");
+ }
+
+ @Test
+ public void testOnOffOnly() throws InterruptedException {
+ // @formatter:off
+ var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"name\": \"light\", " +
+ " \"schema\": \"json\", " +
+ " \"state_topic\": \"zigbee2mqtt/light/state\", " +
+ " \"command_topic\": \"zigbee2mqtt/light/set/state\"" +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("light"));
+
+ assertChannel(component, Light.ON_OFF_CHANNEL_ID, "", "dummy", "On/Off State", OnOffValue.class);
+
+ publishMessage("zigbee2mqtt/light/state", "{ \"state\": \"ON\" }");
+ assertState(component, Light.ON_OFF_CHANNEL_ID, OnOffType.ON);
+ publishMessage("zigbee2mqtt/light/state", "{ \"state\": \"OFF\" }");
+ assertState(component, Light.ON_OFF_CHANNEL_ID, OnOffType.OFF);
+
+ sendCommand(component, Light.ON_OFF_CHANNEL_ID, OnOffType.OFF);
+ assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"OFF\"}");
+ sendCommand(component, Light.ON_OFF_CHANNEL_ID, OnOffType.ON);
+ assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"ON\"}");
+ }
+
+ @Override
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}