diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 000ab956911efd..5bd413cd6b7807 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -8,7 +8,10 @@ ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_RGB_COLOR, ATTR_WHITE_VALUE, + ATTR_XY_COLOR, + COLOR_MODE_RGB, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, @@ -155,11 +158,11 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): def __init__(self, hass, config, config_entry, discovery_data): """Initialize MQTT light.""" - self._state = False self._brightness = None - self._hs = None self._color_temp = None self._effect = None + self._hs_color = None + self._state = False self._white_value = None self._topic = None @@ -256,6 +259,20 @@ async def _subscribe_topics(self): # noqa: C901 last_state = await self.async_get_last_state() + def add_topic(topic, msg_callback): + """Add a topic.""" + if self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": self._config[CONF_QOS], + } + + def restore_state(attribute, condition): + """Restore a state attribute.""" + if condition and last_state and last_state.attributes.get(attribute): + setattr(self, f"_{attribute}", last_state.attributes.get(attribute)) + @callback @log_messages(self.hass, self.entity_id) def state_received(msg): @@ -298,47 +315,44 @@ def brightness_received(msg): self._brightness = percent_bright * 255 self.async_write_ha_state() - if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: - topics[CONF_BRIGHTNESS_STATE_TOPIC] = { - "topic": self._topic[CONF_BRIGHTNESS_STATE_TOPIC], - "msg_callback": brightness_received, - "qos": self._config[CONF_QOS], - } - elif ( - self._optimistic_brightness - and last_state - and last_state.attributes.get(ATTR_BRIGHTNESS) - ): - self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS) + add_topic(CONF_BRIGHTNESS_STATE_TOPIC, brightness_received) + restore_state(ATTR_BRIGHTNESS, self._optimistic_brightness) + + def _rgbx_received(msg, template, color_mode, convert_color): + """Handle new MQTT messages for RGBW and RGBWW.""" + payload = self._value_templates[template](msg.payload, None) + if not payload: + _LOGGER.debug( + "Ignoring empty %s message from '%s'", color_mode, msg.topic + ) + return None + color = tuple(int(val) for val in payload.split(",")) + if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: + rgb = convert_color(*color) + percent_bright = float(color_util.color_RGB_to_hsv(*rgb)[2]) / 100.0 + self._brightness = percent_bright * 255 + return color @callback @log_messages(self.hass, self.entity_id) def rgb_received(msg): """Handle new MQTT messages for RGB.""" - payload = self._value_templates[CONF_RGB_VALUE_TEMPLATE](msg.payload, None) - if not payload: - _LOGGER.debug("Ignoring empty rgb message from '%s'", msg.topic) + rgb = _rgbx_received( + msg, CONF_RGB_VALUE_TEMPLATE, COLOR_MODE_RGB, lambda *x: x + ) + if not rgb: return - - rgb = [int(val) for val in payload.split(",")] - self._hs = color_util.color_RGB_to_hs(*rgb) - if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: - percent_bright = float(color_util.color_RGB_to_hsv(*rgb)[2]) / 100.0 - self._brightness = percent_bright * 255 + self._hs_color = color_util.color_RGB_to_hs(*rgb) self.async_write_ha_state() - if self._topic[CONF_RGB_STATE_TOPIC] is not None: - topics[CONF_RGB_STATE_TOPIC] = { - "topic": self._topic[CONF_RGB_STATE_TOPIC], - "msg_callback": rgb_received, - "qos": self._config[CONF_QOS], - } + add_topic(CONF_RGB_STATE_TOPIC, rgb_received) + restore_state(ATTR_RGB_COLOR, self._optimistic_rgb) if ( self._optimistic_rgb and last_state and last_state.attributes.get(ATTR_HS_COLOR) ): - self._hs = last_state.attributes.get(ATTR_HS_COLOR) + self._hs_color = last_state.attributes.get(ATTR_HS_COLOR) @callback @log_messages(self.hass, self.entity_id) @@ -354,18 +368,8 @@ def color_temp_received(msg): self._color_temp = int(payload) self.async_write_ha_state() - if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: - topics[CONF_COLOR_TEMP_STATE_TOPIC] = { - "topic": self._topic[CONF_COLOR_TEMP_STATE_TOPIC], - "msg_callback": color_temp_received, - "qos": self._config[CONF_QOS], - } - if ( - self._optimistic_color_temp - and last_state - and last_state.attributes.get(ATTR_COLOR_TEMP) - ): - self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + add_topic(CONF_COLOR_TEMP_STATE_TOPIC, color_temp_received) + restore_state(ATTR_COLOR_TEMP, self._optimistic_color_temp) @callback @log_messages(self.hass, self.entity_id) @@ -381,18 +385,8 @@ def effect_received(msg): self._effect = payload self.async_write_ha_state() - if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: - topics[CONF_EFFECT_STATE_TOPIC] = { - "topic": self._topic[CONF_EFFECT_STATE_TOPIC], - "msg_callback": effect_received, - "qos": self._config[CONF_QOS], - } - if ( - self._optimistic_effect - and last_state - and last_state.attributes.get(ATTR_EFFECT) - ): - self._effect = last_state.attributes.get(ATTR_EFFECT) + add_topic(CONF_EFFECT_STATE_TOPIC, effect_received) + restore_state(ATTR_EFFECT, self._optimistic_effect) @callback @log_messages(self.hass, self.entity_id) @@ -404,24 +398,14 @@ def hs_received(msg): return try: - hs_color = [float(val) for val in payload.split(",", 2)] - self._hs = hs_color + hs_color = tuple(float(val) for val in payload.split(",", 2)) + self._hs_color = hs_color self.async_write_ha_state() except ValueError: _LOGGER.debug("Failed to parse hs state update: '%s'", payload) - if self._topic[CONF_HS_STATE_TOPIC] is not None: - topics[CONF_HS_STATE_TOPIC] = { - "topic": self._topic[CONF_HS_STATE_TOPIC], - "msg_callback": hs_received, - "qos": self._config[CONF_QOS], - } - if ( - self._optimistic_hs - and last_state - and last_state.attributes.get(ATTR_HS_COLOR) - ): - self._hs = last_state.attributes.get(ATTR_HS_COLOR) + add_topic(CONF_HS_STATE_TOPIC, hs_received) + restore_state(ATTR_HS_COLOR, self._optimistic_hs) @callback @log_messages(self.hass, self.entity_id) @@ -439,18 +423,8 @@ def white_value_received(msg): self._white_value = percent_white * 255 self.async_write_ha_state() - if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: - topics[CONF_WHITE_VALUE_STATE_TOPIC] = { - "topic": self._topic[CONF_WHITE_VALUE_STATE_TOPIC], - "msg_callback": white_value_received, - "qos": self._config[CONF_QOS], - } - elif ( - self._optimistic_white_value - and last_state - and last_state.attributes.get(ATTR_WHITE_VALUE) - ): - self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + add_topic(CONF_WHITE_VALUE_STATE_TOPIC, white_value_received) + restore_state(ATTR_WHITE_VALUE, self._optimistic_white_value) @callback @log_messages(self.hass, self.entity_id) @@ -461,22 +435,18 @@ def xy_received(msg): _LOGGER.debug("Ignoring empty xy-color message from '%s'", msg.topic) return - xy_color = [float(val) for val in payload.split(",")] - self._hs = color_util.color_xy_to_hs(*xy_color) + xy_color = tuple(float(val) for val in payload.split(",")) + self._hs_color = color_util.color_xy_to_hs(*xy_color) self.async_write_ha_state() - if self._topic[CONF_XY_STATE_TOPIC] is not None: - topics[CONF_XY_STATE_TOPIC] = { - "topic": self._topic[CONF_XY_STATE_TOPIC], - "msg_callback": xy_received, - "qos": self._config[CONF_QOS], - } + add_topic(CONF_XY_STATE_TOPIC, xy_received) + restore_state(ATTR_XY_COLOR, self._optimistic_xy) if ( self._optimistic_xy and last_state and last_state.attributes.get(ATTR_HS_COLOR) ): - self._hs = last_state.attributes.get(ATTR_HS_COLOR) + self._hs_color = last_state.attributes.get(ATTR_HS_COLOR) self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, topics @@ -495,7 +465,7 @@ def hs_color(self): """Return the hs color value.""" if self._white_value: return None - return self._hs + return self._hs_color @property def color_temp(self): @@ -552,6 +522,9 @@ def effect(self): def supported_features(self): """Flag supported features.""" supported_features = 0 + supported_features |= ( + self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None and SUPPORT_EFFECT + ) supported_features |= self._topic[CONF_RGB_COMMAND_TOPIC] is not None and ( SUPPORT_COLOR | SUPPORT_BRIGHTNESS ) @@ -563,9 +536,6 @@ def supported_features(self): self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None and SUPPORT_COLOR_TEMP ) - supported_features |= ( - self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None and SUPPORT_EFFECT - ) supported_features |= ( self._topic[CONF_HS_COMMAND_TOPIC] is not None and SUPPORT_COLOR ) @@ -579,7 +549,7 @@ def supported_features(self): return supported_features - async def async_turn_on(self, **kwargs): # noqa: C901 + async def async_turn_on(self, **kwargs): """Turn the device on. This method is a coroutine. @@ -587,14 +557,47 @@ async def async_turn_on(self, **kwargs): # noqa: C901 should_update = False on_command_type = self._config[CONF_ON_COMMAND_TYPE] - if on_command_type == "first": + def publish(topic, payload): + """Publish an MQTT message.""" mqtt.async_publish( self.hass, - self._topic[CONF_COMMAND_TOPIC], - self._payload["on"], + self._topic[topic], + payload, self._config[CONF_QOS], self._config[CONF_RETAIN], ) + + def scale_rgbx(color, brightness=None): + """Scale RGBx for brightness.""" + if brightness is None: + # If there's a brightness topic set, we don't want to scale the RGBx + # values given using the brightness. + if self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None: + brightness = 255 + else: + brightness = kwargs.get( + ATTR_BRIGHTNESS, self._brightness if self._brightness else 255 + ) + return tuple(round(channel * brightness / 255) for channel in color) + + def render_rgbx(color, template): + """Render RGBx payload.""" + tpl = self._command_templates[template] + if tpl: + keys = ["red", "green", "blue"] + rgb_color_str = tpl(zip(keys, color)) + else: + rgb_color_str = ",".join(str(channel) for channel in color) + return rgb_color_str + + def set_optimistic(attribute, value, condition): + if not condition: + return False + setattr(self, f"_{attribute}", value) + return True + + if on_command_type == "first": + publish(CONF_COMMAND_TOPIC, self._payload["on"]) should_update = True # If brightness is being used instead of an on command, make sure @@ -603,68 +606,33 @@ async def async_turn_on(self, **kwargs): # noqa: C901 elif on_command_type == "brightness" and ATTR_BRIGHTNESS not in kwargs: kwargs[ATTR_BRIGHTNESS] = self._brightness if self._brightness else 255 - if ATTR_HS_COLOR in kwargs and self._topic[CONF_RGB_COMMAND_TOPIC] is not None: - - hs_color = kwargs[ATTR_HS_COLOR] - - # If there's a brightness topic set, we don't want to scale the RGB - # values given using the brightness. - if self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None: - brightness = 255 - else: - brightness = kwargs.get( - ATTR_BRIGHTNESS, self._brightness if self._brightness else 255 - ) - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], brightness / 255 * 100 - ) - tpl = self._command_templates[CONF_RGB_COMMAND_TEMPLATE] - if tpl: - rgb_color_str = tpl({"red": rgb[0], "green": rgb[1], "blue": rgb[2]}) - else: - rgb_color_str = f"{rgb[0]},{rgb[1]},{rgb[2]}" - - mqtt.async_publish( - self.hass, - self._topic[CONF_RGB_COMMAND_TOPIC], - rgb_color_str, - self._config[CONF_QOS], - self._config[CONF_RETAIN], + if ( + ATTR_HS_COLOR in kwargs + and self._topic[CONF_RGB_COMMAND_TOPIC] is not None + and self.supported_color_modes is None + ): + # Convert HS to RGB + rgb = scale_rgbx(color_util.color_hsv_to_RGB(*kwargs[ATTR_HS_COLOR], 100)) + rgb_s = render_rgbx(rgb, CONF_RGB_COMMAND_TEMPLATE) + publish(CONF_RGB_COMMAND_TOPIC, rgb_s) + should_update = set_optimistic( + ATTR_HS_COLOR, kwargs[ATTR_HS_COLOR], self._optimistic_rgb ) - if self._optimistic_rgb: - self._hs = kwargs[ATTR_HS_COLOR] - should_update = True - if ATTR_HS_COLOR in kwargs and self._topic[CONF_HS_COMMAND_TOPIC] is not None: - hs_color = kwargs[ATTR_HS_COLOR] - mqtt.async_publish( - self.hass, - self._topic[CONF_HS_COMMAND_TOPIC], - f"{hs_color[0]},{hs_color[1]}", - self._config[CONF_QOS], - self._config[CONF_RETAIN], - ) - - if self._optimistic_hs: - self._hs = kwargs[ATTR_HS_COLOR] - should_update = True - - if ATTR_HS_COLOR in kwargs and self._topic[CONF_XY_COMMAND_TOPIC] is not None: + publish(CONF_HS_COMMAND_TOPIC, f"{hs_color[0]},{hs_color[1]}") + should_update = set_optimistic(ATTR_HS_COLOR, hs_color, self._optimistic_hs) + if ( + ATTR_HS_COLOR in kwargs + and self._topic[CONF_XY_COMMAND_TOPIC] is not None + and self.supported_color_modes is None + ): + # Legacy mode: Convert HS to XY xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) - mqtt.async_publish( - self.hass, - self._topic[CONF_XY_COMMAND_TOPIC], - f"{xy_color[0]},{xy_color[1]}", - self._config[CONF_QOS], - self._config[CONF_RETAIN], - ) - - if self._optimistic_xy: - self._hs = kwargs[ATTR_HS_COLOR] - should_update = True + publish(CONF_XY_COMMAND_TOPIC, f"{xy_color[0]},{xy_color[1]}") + should_update = set_optimistic(ATTR_HS_COLOR, hs_color, self._optimistic_xy) if ( ATTR_BRIGHTNESS in kwargs @@ -677,43 +645,23 @@ async def async_turn_on(self, **kwargs): # noqa: C901 ) # Make sure the brightness is not rounded down to 0 device_brightness = max(device_brightness, 1) - mqtt.async_publish( - self.hass, - self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC], - device_brightness, - self._config[CONF_QOS], - self._config[CONF_RETAIN], + publish(CONF_BRIGHTNESS_COMMAND_TOPIC, device_brightness) + should_update = set_optimistic( + ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS], self._optimistic_brightness ) - - if self._optimistic_brightness: - self._brightness = kwargs[ATTR_BRIGHTNESS] - should_update = True elif ( ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR not in kwargs and self._topic[CONF_RGB_COMMAND_TOPIC] is not None ): - hs_color = self._hs if self._hs is not None else (0, 0) - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], kwargs[ATTR_BRIGHTNESS] / 255 * 100 + hs_color = self._hs_color if self._hs_color is not None else (0, 0) + brightness = kwargs[ATTR_BRIGHTNESS] + rgb = scale_rgbx(color_util.color_hsv_to_RGB(*hs_color, 100), brightness) + rgb_s = render_rgbx(rgb, CONF_RGB_COMMAND_TEMPLATE) + publish(CONF_RGB_COMMAND_TOPIC, rgb_s) + should_update = set_optimistic( + ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS], self._optimistic_brightness ) - tpl = self._command_templates[CONF_RGB_COMMAND_TEMPLATE] - if tpl: - rgb_color_str = tpl({"red": rgb[0], "green": rgb[1], "blue": rgb[2]}) - else: - rgb_color_str = f"{rgb[0]},{rgb[1]},{rgb[2]}" - - mqtt.async_publish( - self.hass, - self._topic[CONF_RGB_COMMAND_TOPIC], - rgb_color_str, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - ) - - if self._optimistic_brightness: - self._brightness = kwargs[ATTR_BRIGHTNESS] - should_update = True if ( ATTR_COLOR_TEMP in kwargs @@ -721,37 +669,24 @@ async def async_turn_on(self, **kwargs): # noqa: C901 ): color_temp = int(kwargs[ATTR_COLOR_TEMP]) tpl = self._command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE] - if tpl: color_temp = tpl({"value": color_temp}) - mqtt.async_publish( - self.hass, - self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC], - color_temp, - self._config[CONF_QOS], - self._config[CONF_RETAIN], + publish(CONF_COLOR_TEMP_COMMAND_TOPIC, color_temp) + should_update = set_optimistic( + ATTR_COLOR_TEMP, + kwargs[ATTR_COLOR_TEMP], + self._optimistic_color_temp, ) - if self._optimistic_color_temp: - self._color_temp = kwargs[ATTR_COLOR_TEMP] - should_update = True - if ATTR_EFFECT in kwargs and self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None: effect = kwargs[ATTR_EFFECT] if effect in self._config.get(CONF_EFFECT_LIST): - mqtt.async_publish( - self.hass, - self._topic[CONF_EFFECT_COMMAND_TOPIC], - effect, - self._config[CONF_QOS], - self._config[CONF_RETAIN], + publish(CONF_EFFECT_COMMAND_TOPIC, effect) + should_update = set_optimistic( + ATTR_EFFECT, effect, self._optimistic_effect ) - if self._optimistic_effect: - self._effect = kwargs[ATTR_EFFECT] - should_update = True - if ( ATTR_WHITE_VALUE in kwargs and self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None @@ -759,26 +694,13 @@ async def async_turn_on(self, **kwargs): # noqa: C901 percent_white = float(kwargs[ATTR_WHITE_VALUE]) / 255 white_scale = self._config[CONF_WHITE_VALUE_SCALE] device_white_value = min(round(percent_white * white_scale), white_scale) - mqtt.async_publish( - self.hass, - self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC], - device_white_value, - self._config[CONF_QOS], - self._config[CONF_RETAIN], + publish(CONF_WHITE_VALUE_COMMAND_TOPIC, device_white_value) + should_update = set_optimistic( + ATTR_WHITE_VALUE, kwargs[ATTR_WHITE_VALUE], self._optimistic_white_value ) - if self._optimistic_white_value: - self._white_value = kwargs[ATTR_WHITE_VALUE] - should_update = True - if on_command_type == "last": - mqtt.async_publish( - self.hass, - self._topic[CONF_COMMAND_TOPIC], - self._payload["on"], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - ) + publish(CONF_COMMAND_TOPIC, self._payload["on"]) should_update = True if self._optimistic: diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index e995b373d03706..8b7ecea95e5802 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -1185,7 +1185,7 @@ async def test_on_command_rgb(hass, mqtt_mock): mqtt_mock.async_publish.assert_has_calls( [ - call("test_light/rgb", "1,0,0", 0, False), + call("test_light/rgb", "1,1,0", 0, False), call("test_light/set", "ON", 0, False), ], any_order=True,