From 363b88407c1d4bcf23543ece497f4995c946a602 Mon Sep 17 00:00:00 2001 From: Ben Jones Date: Tue, 1 Apr 2025 10:16:22 +1300 Subject: [PATCH] Handle empty or missing state values for MQTT light entities using 'template' schema (#141177) * check for empty or missing values when processing state messages for MQTT light entities using 'template' schema * normalise warning logs * add tests (one is still failing and I can't work out why) * fix test * improve test coverage after PR review * improve test coverage after PR review --- .../components/mqtt/light/schema_template.py | 159 ++++++++++++------ tests/components/mqtt/test_light_template.py | 106 ++++++++++++ 2 files changed, 217 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 595f072416b..f561f15fb51 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -62,6 +62,7 @@ from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, + PayloadSentinel, PublishPayloadType, ReceiveMessage, ) @@ -126,7 +127,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] - _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] + _value_templates: dict[ + str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType] + ] _fixed_color_mode: ColorMode | str | None _topics: dict[str, str | None] @@ -203,73 +206,133 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): @callback def _state_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" - state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) - if state == STATE_ON: + state_value = self._value_templates[CONF_STATE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not state_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty state value", msg.topic + ) + elif state_value == STATE_ON: self._attr_is_on = True - elif state == STATE_OFF: + elif state_value == STATE_OFF: self._attr_is_on = False - elif state == PAYLOAD_NONE: + elif state_value == PAYLOAD_NONE: self._attr_is_on = None else: - _LOGGER.warning("Invalid state value received") + _LOGGER.warning( + "Invalid state value '%s' received from %s", + state_value, + msg.topic, + ) if CONF_BRIGHTNESS_TEMPLATE in self._config: - try: - if brightness := int( - self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ): - self._attr_brightness = brightness - else: - _LOGGER.debug( - "Ignoring zero brightness value for entity %s", - self.entity_id, + brightness_value = self._value_templates[CONF_BRIGHTNESS_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not brightness_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty brightness value", + msg.topic, + ) + else: + try: + if brightness := int(brightness_value): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except ValueError: + _LOGGER.warning( + "Invalid brightness value '%s' received from %s", + brightness_value, + msg.topic, ) - except ValueError: - _LOGGER.warning("Invalid brightness value received from %s", msg.topic) - if CONF_COLOR_TEMP_TEMPLATE in self._config: - try: - color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( - msg.payload + color_temp_value = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not color_temp_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color temperature value", + msg.topic, ) - self._attr_color_temp_kelvin = ( - int(color_temp) - if self._color_temp_kelvin - else color_util.color_temperature_mired_to_kelvin(int(color_temp)) - if color_temp != "None" - else None - ) - except ValueError: - _LOGGER.warning("Invalid color temperature value received") + else: + try: + self._attr_color_temp_kelvin = ( + int(color_temp_value) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + int(color_temp_value) + ) + if color_temp_value != "None" + else None + ) + except ValueError: + _LOGGER.warning( + "Invalid color temperature value '%s' received from %s", + color_temp_value, + msg.topic, + ) if ( CONF_RED_TEMPLATE in self._config and CONF_GREEN_TEMPLATE in self._config and CONF_BLUE_TEMPLATE in self._config ): - try: - red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) - green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) - blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) - if red == "None" and green == "None" and blue == "None": - self._attr_hs_color = None - else: - self._attr_hs_color = color_util.color_RGB_to_hs( - int(red), int(green), int(blue) - ) + red_value = self._value_templates[CONF_RED_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + green_value = self._value_templates[CONF_GREEN_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + blue_value = self._value_templates[CONF_BLUE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not red_value or not green_value or not blue_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color value", msg.topic + ) + elif red_value == "None" and green_value == "None" and blue_value == "None": + self._attr_hs_color = None self._update_color_mode() - except ValueError: - _LOGGER.warning("Invalid color value received") + else: + try: + self._attr_hs_color = color_util.color_RGB_to_hs( + int(red_value), int(green_value), int(blue_value) + ) + self._update_color_mode() + except ValueError: + _LOGGER.warning("Invalid color value received from %s", msg.topic) if CONF_EFFECT_TEMPLATE in self._config: - effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) - if ( - effect_list := self._config[CONF_EFFECT_LIST] - ) and effect in effect_list: - self._attr_effect = effect + effect_value = self._value_templates[CONF_EFFECT_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not effect_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty effect value", msg.topic + ) + elif (effect_list := self._config[CONF_EFFECT_LIST]) and str( + effect_value + ) in effect_list: + self._attr_effect = str(effect_value) else: - _LOGGER.warning("Unsupported effect value received") + _LOGGER.warning( + "Unsupported effect value '%s' received from %s", + effect_value, + msg.topic, + ) @callback def _prepare_subscribe_topics(self) -> None: diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index b3a1c11c2b6..e2cc801e97d 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -1545,3 +1545,109 @@ async def test_rgb_value_template_fails( "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "effect_list": ["rainbow", "colorloop"], + "state_topic": "test-topic", + "state_template": "{{ value_json.state }}", + "brightness_template": "{{ value_json.brightness }}", + "color_temp_template": "{{ value_json.color_temp }}", + "red_template": "{{ value_json.color.red }}", + "green_template": "{{ value_json.color.green }}", + "blue_template": "{{ value_json.color.blue }}", + "effect_template": "{{ value_json.effect }}", + }, + ), + ) + ], +) +async def test_state_templates_ignore_missing_values( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test that rendering of MQTT value template ignores missing values.""" + await mqtt_mock_entry() + + # turn on the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "on"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + + # update brightness and color temperature (with no state) + async_fire_mqtt_message( + hass, "test-topic", '{"brightness": 255, "color_temp": 145}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == ( + 246, + 244, + 255, + ) # temp converted to color + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") == 6896 + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") == (0.317, 0.317) # temp converted to color + assert state.attributes.get("hs_color") == ( + 251.249, + 4.253, + ) # temp converted to color + + # update color + async_fire_mqtt_message( + hass, "test-topic", '{"color": {"red": 255, "green": 128, "blue": 64}}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update brightness + async_fire_mqtt_message(hass, "test-topic", '{"brightness": 128}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "rainbow"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # invalid effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "invalid"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # turn off the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "off"}') + state = hass.states.get("light.test") + assert state.state == STATE_OFF + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None