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
This commit is contained in:
Ben Jones 2025-04-01 10:16:22 +13:00 committed by Franck Nijhof
parent fee152654d
commit 3951c2ea66
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
2 changed files with 217 additions and 48 deletions

View File

@ -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:

View File

@ -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