From f98f820325d33f301f3fa6095efdf8eb9dfd2769 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Sun, 26 Mar 2023 10:07:20 -0600 Subject: [PATCH] [mqtt.homeassistant] Implement JSON schema lights (#13808) * [mqtt.homeassistant] implement JSON schema lights * [mqtt.homeassistant] use enum for current state of color mode * [mqtt.homeassistant] use implicit lambdas * [mqtt.homeassistant] remove string constants in favor of an enum * [mqtt.homeassistant] allow sending ON and brightness commands through bare * [mqtt.homeassistant] turn down debug logging --------- Signed-off-by: Cody Cutrer --- .../internal/component/AbstractComponent.java | 6 + .../component/AbstractRawSchemaLight.java | 69 ++++++ .../internal/component/JSONSchemaLight.java | 234 ++++++++++++++++++ .../internal/component/Light.java | 4 +- .../internal/component/LightColorMode.java | 64 +++++ .../component/JSONSchemaLightTests.java | 157 ++++++++++++ 6 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightColorMode.java create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLightTests.java diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java index d4ab2251cbc..203218baa75 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java @@ -39,6 +39,8 @@ import org.openhab.core.thing.type.ChannelGroupType; 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. @@ -243,4 +245,8 @@ public abstract class AbstractComponent public boolean isEnabledByDefault() { return channelConfiguration.isEnabledByDefault(); } + + public Gson getGson() { + return componentConfiguration.getGson(); + } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java new file mode 100644 index 00000000000..728c25ecdee --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java @@ -0,0 +1,69 @@ +/** + * 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); +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java new file mode 100644 index 00000000000..722af0b42eb --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java @@ -0,0 +1,234 @@ +/** + * 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 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()); + } + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java index f389d3771bb..782268cdab0 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java @@ -92,7 +92,7 @@ public abstract class Light extends AbstractComponent supportedColorModes; // JSON schema only + protected @Nullable List 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 @@ -257,6 +257,8 @@ public abstract class Light extends AbstractComponent WITH_RGB = List.of(COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW); + public static final List 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 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 supportedColorModes) { + return WITH_RGB.stream().anyMatch(cm -> supportedColorModes.contains(cm)); + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLightTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLightTests.java new file mode 100644 index 00000000000..a33957479b1 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLightTests.java @@ -0,0 +1,157 @@ +/** + * 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 getConfigTopics() { + return Set.of(CONFIG_TOPIC); + } +}