diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 6c572c093a3db..1c94fae9d291b 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -220,6 +220,8 @@ "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", "val_tpl": "value_template", + "whit_cmd_t": "white_command_topic", + "whit_scl": "white_scale", "whit_val_cmd_t": "white_value_command_topic", "whit_val_scl": "white_value_scale", "whit_val_stat_t": "white_value_state_topic", diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 2030cfb88257d..214da1dd7bff4 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -12,6 +12,7 @@ ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, COLOR_MODE_BRIGHTNESS, @@ -22,6 +23,7 @@ COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODE_UNKNOWN, + COLOR_MODE_WHITE, COLOR_MODE_XY, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, @@ -29,6 +31,7 @@ SUPPORT_EFFECT, SUPPORT_WHITE_VALUE, LightEntity, + valid_supported_color_modes, ) from homeassistant.const import ( CONF_NAME, @@ -86,6 +89,8 @@ CONF_XY_COMMAND_TOPIC = "xy_command_topic" CONF_XY_STATE_TOPIC = "xy_state_topic" CONF_XY_VALUE_TEMPLATE = "xy_value_template" +CONF_WHITE_COMMAND_TOPIC = "white_command_topic" +CONF_WHITE_SCALE = "white_scale" CONF_WHITE_VALUE_COMMAND_TOPIC = "white_value_command_topic" CONF_WHITE_VALUE_SCALE = "white_value_scale" CONF_WHITE_VALUE_STATE_TOPIC = "white_value_state_topic" @@ -98,6 +103,7 @@ DEFAULT_PAYLOAD_OFF = "OFF" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_WHITE_VALUE_SCALE = 255 +DEFAULT_WHITE_SCALE = 255 DEFAULT_ON_COMMAND_TYPE = "last" VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] @@ -168,6 +174,10 @@ vol.Optional(CONF_RGBWW_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_RGBWW_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_WHITE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_WHITE_VALUE_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional( @@ -259,6 +269,7 @@ def _setup_from_config(self, config): CONF_RGBWW_COMMAND_TOPIC, CONF_RGBWW_STATE_TOPIC, CONF_STATE_TOPIC, + CONF_WHITE_COMMAND_TOPIC, CONF_WHITE_VALUE_COMMAND_TOPIC, CONF_WHITE_VALUE_STATE_TOPIC, CONF_XY_COMMAND_TOPIC, @@ -316,35 +327,40 @@ def _setup_from_config(self, config): optimistic or topic[CONF_WHITE_VALUE_STATE_TOPIC] is None ) self._optimistic_xy_color = optimistic or topic[CONF_XY_STATE_TOPIC] is None - self._supported_color_modes = set() + supported_color_modes = set() if topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None: - self._supported_color_modes.add(COLOR_MODE_COLOR_TEMP) + supported_color_modes.add(COLOR_MODE_COLOR_TEMP) self._color_mode = COLOR_MODE_COLOR_TEMP if topic[CONF_HS_COMMAND_TOPIC] is not None: - self._supported_color_modes.add(COLOR_MODE_HS) + supported_color_modes.add(COLOR_MODE_HS) self._color_mode = COLOR_MODE_HS if topic[CONF_RGB_COMMAND_TOPIC] is not None: - self._supported_color_modes.add(COLOR_MODE_RGB) + supported_color_modes.add(COLOR_MODE_RGB) self._color_mode = COLOR_MODE_RGB if topic[CONF_RGBW_COMMAND_TOPIC] is not None: - self._supported_color_modes.add(COLOR_MODE_RGBW) + supported_color_modes.add(COLOR_MODE_RGBW) self._color_mode = COLOR_MODE_RGBW if topic[CONF_RGBWW_COMMAND_TOPIC] is not None: - self._supported_color_modes.add(COLOR_MODE_RGBWW) + supported_color_modes.add(COLOR_MODE_RGBWW) self._color_mode = COLOR_MODE_RGBWW + if topic[CONF_WHITE_COMMAND_TOPIC] is not None: + supported_color_modes.add(COLOR_MODE_WHITE) if topic[CONF_XY_COMMAND_TOPIC] is not None: - self._supported_color_modes.add(COLOR_MODE_XY) + supported_color_modes.add(COLOR_MODE_XY) self._color_mode = COLOR_MODE_XY - if len(self._supported_color_modes) > 1: + if len(supported_color_modes) > 1: self._color_mode = COLOR_MODE_UNKNOWN - if not self._supported_color_modes: + if not supported_color_modes: if topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None: self._color_mode = COLOR_MODE_BRIGHTNESS - self._supported_color_modes.add(COLOR_MODE_BRIGHTNESS) + supported_color_modes.add(COLOR_MODE_BRIGHTNESS) else: self._color_mode = COLOR_MODE_ONOFF - self._supported_color_modes.add(COLOR_MODE_ONOFF) + supported_color_modes.add(COLOR_MODE_ONOFF) + + # Validate the color_modes configuration + self._supported_color_modes = valid_supported_color_modes(supported_color_modes) if topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None: self._legacy_mode = True @@ -817,7 +833,11 @@ def set_optimistic(attribute, value, color_mode=None, condition_attribute=None): # If brightness is being used instead of an on command, make sure # there is a brightness input. Either set the brightness to our # saved value or the maximum value if this is the first call - elif on_command_type == "brightness" and ATTR_BRIGHTNESS not in kwargs: + elif ( + on_command_type == "brightness" + and ATTR_BRIGHTNESS not in kwargs + and ATTR_WHITE not in kwargs + ): kwargs[ATTR_BRIGHTNESS] = self._brightness if self._brightness else 255 hs_color = kwargs.get(ATTR_HS_COLOR) @@ -971,6 +991,17 @@ def set_optimistic(attribute, value, color_mode=None, condition_attribute=None): publish(CONF_EFFECT_COMMAND_TOPIC, effect) should_update |= set_optimistic(ATTR_EFFECT, effect) + if ATTR_WHITE in kwargs and self._topic[CONF_WHITE_COMMAND_TOPIC] is not None: + percent_white = float(kwargs[ATTR_WHITE]) / 255 + white_scale = self._config[CONF_WHITE_SCALE] + device_white_value = min(round(percent_white * white_scale), white_scale) + publish(CONF_WHITE_COMMAND_TOPIC, device_white_value) + should_update |= set_optimistic( + ATTR_BRIGHTNESS, + kwargs[ATTR_WHITE], + COLOR_MODE_WHITE, + ) + if ( ATTR_WHITE_VALUE in kwargs and self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None diff --git a/tests/components/light/common.py b/tests/components/light/common.py index 229823ceb174f..97181eb2447a9 100644 --- a/tests/components/light/common.py +++ b/tests/components/light/common.py @@ -17,6 +17,7 @@ ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_TRANSITION, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, DOMAIN, @@ -92,6 +93,7 @@ async def async_turn_on( flash=None, effect=None, color_name=None, + white=None, ): """Turn all or specified light on.""" data = { @@ -113,6 +115,7 @@ async def async_turn_on( (ATTR_FLASH, flash), (ATTR_EFFECT, effect), (ATTR_COLOR_NAME, color_name), + (ATTR_WHITE, white), ] if value is not None } diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 728656743e5c9..efd3b1b2424b1 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -2268,6 +2268,83 @@ async def test_on_command_rgbww_template(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +async def test_on_command_white(hass, mqtt_mock): + """Test sending commands for RGB + white light.""" + config = { + light.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "tasmota_B94927/cmnd/POWER", + "value_template": "{{ value_json.POWER }}", + "payload_off": "OFF", + "payload_on": "ON", + "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", + "brightness_scale": 100, + "on_command_type": "brightness", + "brightness_value_template": "{{ value_json.Dimmer }}", + "rgb_command_topic": "tasmota_B94927/cmnd/Color2", + "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", + "white_command_topic": "tasmota_B94927/cmnd/White", + "white_scale": 100, + "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", + "qos": "0", + } + } + color_modes = ["rgb", "white"] + + assert await async_setup_component(hass, light.DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + assert state.attributes.get("brightness") is None + assert state.attributes.get("rgb_color") is None + assert state.attributes.get(light.ATTR_COLOR_MODE) is None + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "light.test", brightness=192) + mqtt_mock.async_publish.assert_has_calls( + [ + call("tasmota_B94927/cmnd/Dimmer", "75", 0, False), + ], + any_order=True, + ) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_on(hass, "light.test", white=255) + mqtt_mock.async_publish.assert_has_calls( + [ + call("tasmota_B94927/cmnd/White", "100", 0, False), + ], + any_order=True, + ) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_on(hass, "light.test", white=64) + mqtt_mock.async_publish.assert_has_calls( + [ + call("tasmota_B94927/cmnd/White", "25", 0, False), + ], + any_order=True, + ) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_on(hass, "light.test") + mqtt_mock.async_publish.assert_has_calls( + [ + call("tasmota_B94927/cmnd/Dimmer", "25", 0, False), + ], + any_order=True, + ) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_off(hass, "light.test") + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_B94927/cmnd/POWER", "OFF", 0, False + ) + + async def test_explicit_color_mode(hass, mqtt_mock): """Test explicit color mode over mqtt.""" config = { @@ -2499,6 +2576,70 @@ async def test_explicit_color_mode_templated(hass, mqtt_mock): assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +async def test_white_state_update(hass, mqtt_mock): + """Test state updates for RGB + white light.""" + config = { + light.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "tasmota_B94927/tele/STATE", + "command_topic": "tasmota_B94927/cmnd/POWER", + "value_template": "{{ value_json.POWER }}", + "payload_off": "OFF", + "payload_on": "ON", + "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", + "brightness_state_topic": "tasmota_B94927/tele/STATE", + "brightness_scale": 100, + "on_command_type": "brightness", + "brightness_value_template": "{{ value_json.Dimmer }}", + "rgb_command_topic": "tasmota_B94927/cmnd/Color2", + "rgb_state_topic": "tasmota_B94927/tele/STATE", + "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", + "white_command_topic": "tasmota_B94927/cmnd/White", + "white_scale": 100, + "color_mode_state_topic": "tasmota_B94927/tele/STATE", + "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", + "qos": "0", + } + } + color_modes = ["rgb", "white"] + + assert await async_setup_component(hass, light.DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + assert state.attributes.get("brightness") is None + assert state.attributes.get("rgb_color") is None + assert state.attributes.get(light.ATTR_COLOR_MODE) is None + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message( + hass, + "tasmota_B94927/tele/STATE", + '{"POWER":"ON","Dimmer":50,"Color":"0,0,0,128","White":50}', + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("rgb_color") is None + assert state.attributes.get(light.ATTR_COLOR_MODE) == "white" + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + + async_fire_mqtt_message( + hass, + "tasmota_B94927/tele/STATE", + '{"POWER":"ON","Dimmer":50,"Color":"128,64,32,0","White":0}', + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("rgb_color") == (128, 64, 32) + assert state.attributes.get(light.ATTR_COLOR_MODE) == "rgb" + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + + async def test_effect(hass, mqtt_mock): """Test effect.""" config = {