Consequently ignore empty MQTT state payloads and set state to unknown on "None" payload (#117813)

* Consequently ignore empty MQTT state payloads and set state to `unknown` on "None" payload

* Do not change preset mode behavior

* Add device tracker ignoring empty state

* Ignore empty state for lock

* Resolve merge errors
This commit is contained in:
Jan Bouwhuis 2024-05-25 01:29:43 +02:00 committed by GitHub
parent c616fc036e
commit 01f3a5a97c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 183 additions and 26 deletions

View file

@ -40,6 +40,7 @@ from .const import (
CONF_RETAIN, CONF_RETAIN,
CONF_STATE_TOPIC, CONF_STATE_TOPIC,
CONF_SUPPORTED_FEATURES, CONF_SUPPORTED_FEATURES,
PAYLOAD_NONE,
) )
from .mixins import MqttEntity, async_setup_entity_entry_helper from .mixins import MqttEntity, async_setup_entity_entry_helper
from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage
@ -176,6 +177,16 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity):
def _state_message_received(self, msg: ReceiveMessage) -> None: def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Run when new MQTT message has been received.""" """Run when new MQTT message has been received."""
payload = self._value_template(msg.payload) payload = self._value_template(msg.payload)
if not payload.strip(): # No output from template, ignore
_LOGGER.debug(
"Ignoring empty payload '%s' after rendering for topic %s",
payload,
msg.topic,
)
return
if payload == PAYLOAD_NONE:
self._attr_state = None
return
if payload not in ( if payload not in (
STATE_ALARM_DISARMED, STATE_ALARM_DISARMED,
STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_HOME,

View file

@ -709,13 +709,16 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
def _handle_action_received(self, msg: ReceiveMessage) -> None: def _handle_action_received(self, msg: ReceiveMessage) -> None:
"""Handle receiving action via MQTT.""" """Handle receiving action via MQTT."""
payload = self.render_template(msg, CONF_ACTION_TEMPLATE) payload = self.render_template(msg, CONF_ACTION_TEMPLATE)
if not payload or payload == PAYLOAD_NONE: if not payload:
_LOGGER.debug( _LOGGER.debug(
"Invalid %s action: %s, ignoring", "Invalid %s action: %s, ignoring",
[e.value for e in HVACAction], [e.value for e in HVACAction],
payload, payload,
) )
return return
if payload == PAYLOAD_NONE:
self._attr_hvac_action = None
return
try: try:
self._attr_hvac_action = HVACAction(str(payload)) self._attr_hvac_action = HVACAction(str(payload))
except ValueError: except ValueError:
@ -733,8 +736,10 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
"""Handle receiving listed mode via MQTT.""" """Handle receiving listed mode via MQTT."""
payload = self.render_template(msg, template_name) payload = self.render_template(msg, template_name)
if payload not in self._config[mode_list]: if payload == PAYLOAD_NONE:
_LOGGER.error("Invalid %s mode: %s", mode_list, payload) setattr(self, attr, None)
elif payload not in self._config[mode_list]:
_LOGGER.warning("Invalid %s mode: %s", mode_list, payload)
else: else:
setattr(self, attr, payload) setattr(self, attr, payload)

View file

@ -62,6 +62,7 @@ from .const import (
DEFAULT_POSITION_CLOSED, DEFAULT_POSITION_CLOSED,
DEFAULT_POSITION_OPEN, DEFAULT_POSITION_OPEN,
DEFAULT_RETAIN, DEFAULT_RETAIN,
PAYLOAD_NONE,
) )
from .mixins import MqttEntity, async_setup_entity_entry_helper from .mixins import MqttEntity, async_setup_entity_entry_helper
from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage
@ -350,9 +351,13 @@ class MqttCover(MqttEntity, CoverEntity):
self._attr_supported_features = supported_features self._attr_supported_features = supported_features
@callback @callback
def _update_state(self, state: str) -> None: def _update_state(self, state: str | None) -> None:
"""Update the cover state.""" """Update the cover state."""
self._attr_is_closed = state == STATE_CLOSED if state is None:
# Reset the state to `unknown`
self._attr_is_closed = None
else:
self._attr_is_closed = state == STATE_CLOSED
self._attr_is_opening = state == STATE_OPENING self._attr_is_opening = state == STATE_OPENING
self._attr_is_closing = state == STATE_CLOSING self._attr_is_closing = state == STATE_CLOSING
@ -376,7 +381,7 @@ class MqttCover(MqttEntity, CoverEntity):
_LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic)
return return
state: str state: str | None
if payload == self._config[CONF_STATE_STOPPED]: if payload == self._config[CONF_STATE_STOPPED]:
if self._config.get(CONF_GET_POSITION_TOPIC) is not None: if self._config.get(CONF_GET_POSITION_TOPIC) is not None:
state = ( state = (
@ -398,6 +403,8 @@ class MqttCover(MqttEntity, CoverEntity):
state = STATE_OPEN state = STATE_OPEN
elif payload == self._config[CONF_STATE_CLOSED]: elif payload == self._config[CONF_STATE_CLOSED]:
state = STATE_CLOSED state = STATE_CLOSED
elif payload == PAYLOAD_NONE:
state = None
else: else:
_LOGGER.warning( _LOGGER.warning(
( (

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import voluptuous as vol import voluptuous as vol
@ -42,6 +43,8 @@ from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType
from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_subscribe_topic from .util import valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_HOME = "payload_home"
CONF_PAYLOAD_NOT_HOME = "payload_not_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home"
CONF_SOURCE_TYPE = "source_type" CONF_SOURCE_TYPE = "source_type"
@ -125,6 +128,13 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
def message_received(msg: ReceiveMessage) -> None: def message_received(msg: ReceiveMessage) -> None:
"""Handle new MQTT messages.""" """Handle new MQTT messages."""
payload = self._value_template(msg.payload) payload = self._value_template(msg.payload)
if not payload.strip(): # No output from template, ignore
_LOGGER.debug(
"Ignoring empty payload '%s' after rendering for topic %s",
payload,
msg.topic,
)
return
if payload == self._config[CONF_PAYLOAD_HOME]: if payload == self._config[CONF_PAYLOAD_HOME]:
self._location_name = STATE_HOME self._location_name = STATE_HOME
elif payload == self._config[CONF_PAYLOAD_NOT_HOME]: elif payload == self._config[CONF_PAYLOAD_NOT_HOME]:

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import logging
import re import re
from typing import Any from typing import Any
@ -50,6 +51,8 @@ from .models import (
) )
from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
CONF_CODE_FORMAT = "code_format" CONF_CODE_FORMAT = "code_format"
CONF_PAYLOAD_LOCK = "payload_lock" CONF_PAYLOAD_LOCK = "payload_lock"
@ -205,9 +208,15 @@ class MqttLock(MqttEntity, LockEntity):
) )
def message_received(msg: ReceiveMessage) -> None: def message_received(msg: ReceiveMessage) -> None:
"""Handle new lock state messages.""" """Handle new lock state messages."""
if (payload := self._value_template(msg.payload)) == self._config[ payload = self._value_template(msg.payload)
CONF_PAYLOAD_RESET if not payload.strip(): # No output from template, ignore
]: _LOGGER.debug(
"Ignoring empty payload '%s' after rendering for topic %s",
payload,
msg.topic,
)
return
if payload == self._config[CONF_PAYLOAD_RESET]:
# Reset the state to `unknown` # Reset the state to `unknown`
self._attr_is_locked = None self._attr_is_locked = None
elif payload in self._valid_states: elif payload in self._valid_states:

View file

@ -122,6 +122,13 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity):
def message_received(msg: ReceiveMessage) -> None: def message_received(msg: ReceiveMessage) -> None:
"""Handle new MQTT messages.""" """Handle new MQTT messages."""
payload = str(self._value_template(msg.payload)) payload = str(self._value_template(msg.payload))
if not payload.strip(): # No output from template, ignore
_LOGGER.debug(
"Ignoring empty payload '%s' after rendering for topic %s",
payload,
msg.topic,
)
return
if payload.lower() == "none": if payload.lower() == "none":
self._attr_current_option = None self._attr_current_option = None
return return

View file

@ -59,6 +59,7 @@ from .const import (
DEFAULT_POSITION_CLOSED, DEFAULT_POSITION_CLOSED,
DEFAULT_POSITION_OPEN, DEFAULT_POSITION_OPEN,
DEFAULT_RETAIN, DEFAULT_RETAIN,
PAYLOAD_NONE,
) )
from .debug_info import log_messages from .debug_info import log_messages
from .mixins import ( from .mixins import (
@ -220,13 +221,16 @@ class MqttValve(MqttEntity, ValveEntity):
self._attr_supported_features = supported_features self._attr_supported_features = supported_features
@callback @callback
def _update_state(self, state: str) -> None: def _update_state(self, state: str | None) -> None:
"""Update the valve state properties.""" """Update the valve state properties."""
self._attr_is_opening = state == STATE_OPENING self._attr_is_opening = state == STATE_OPENING
self._attr_is_closing = state == STATE_CLOSING self._attr_is_closing = state == STATE_CLOSING
if self.reports_position: if self.reports_position:
return return
self._attr_is_closed = state == STATE_CLOSED if state is None:
self._attr_is_closed = None
else:
self._attr_is_closed = state == STATE_CLOSED
@callback @callback
def _process_binary_valve_update( def _process_binary_valve_update(
@ -242,7 +246,9 @@ class MqttValve(MqttEntity, ValveEntity):
state = STATE_OPEN state = STATE_OPEN
elif state_payload == self._config[CONF_STATE_CLOSED]: elif state_payload == self._config[CONF_STATE_CLOSED]:
state = STATE_CLOSED state = STATE_CLOSED
if state is None: elif state_payload == PAYLOAD_NONE:
state = None
else:
_LOGGER.warning( _LOGGER.warning(
"Payload received on topic '%s' is not one of " "Payload received on topic '%s' is not one of "
"[open, closed, opening, closing], got: %s", "[open, closed, opening, closing], got: %s",
@ -263,6 +269,9 @@ class MqttValve(MqttEntity, ValveEntity):
state = STATE_OPENING state = STATE_OPENING
elif state_payload == self._config[CONF_STATE_CLOSING]: elif state_payload == self._config[CONF_STATE_CLOSING]:
state = STATE_CLOSING state = STATE_CLOSING
elif state_payload == PAYLOAD_NONE:
self._attr_current_valve_position = None
return
if state is None or position_payload != state_payload: if state is None or position_payload != state_payload:
try: try:
percentage_payload = ranged_value_to_percentage( percentage_payload = ranged_value_to_percentage(

View file

@ -63,6 +63,7 @@ from .const import (
CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TEMPLATE,
CONF_TEMP_STATE_TOPIC, CONF_TEMP_STATE_TOPIC,
DEFAULT_OPTIMISTIC, DEFAULT_OPTIMISTIC,
PAYLOAD_NONE,
) )
from .mixins import async_setup_entity_entry_helper from .mixins import async_setup_entity_entry_helper
from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage
@ -259,10 +260,22 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity):
@callback @callback
def _handle_current_mode_received(self, msg: ReceiveMessage) -> None: def _handle_current_mode_received(self, msg: ReceiveMessage) -> None:
"""Handle receiving operation mode via MQTT.""" """Handle receiving operation mode via MQTT."""
payload = self.render_template(msg, CONF_MODE_STATE_TEMPLATE) payload = self.render_template(msg, CONF_MODE_STATE_TEMPLATE)
if payload not in self._config[CONF_MODE_LIST]: if not payload.strip(): # No output from template, ignore
_LOGGER.error("Invalid %s mode: %s", CONF_MODE_LIST, payload) _LOGGER.debug(
"Ignoring empty payload '%s' for current operation "
"after rendering for topic %s",
payload,
msg.topic,
)
return
if payload == PAYLOAD_NONE:
self._attr_current_operation = None
elif payload not in self._config[CONF_MODE_LIST]:
_LOGGER.warning("Invalid %s mode: %s", CONF_MODE_LIST, payload)
else: else:
if TYPE_CHECKING: if TYPE_CHECKING:
assert isinstance(payload, str) assert isinstance(payload, str)

View file

@ -209,6 +209,14 @@ async def test_update_state_via_state_topic(
async_fire_mqtt_message(hass, "alarm/state", state) async_fire_mqtt_message(hass, "alarm/state", state)
assert hass.states.get(entity_id).state == state assert hass.states.get(entity_id).state == state
# Ignore empty payload (last state is STATE_ALARM_TRIGGERED)
async_fire_mqtt_message(hass, "alarm/state", "")
assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED
# Reset state on `None` payload
async_fire_mqtt_message(hass, "alarm/state", "None")
assert hass.states.get(entity_id).state == STATE_UNKNOWN
@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG])
async def test_ignore_update_state_if_unknown_via_state_topic( async def test_ignore_update_state_if_unknown_via_state_topic(

View file

@ -32,7 +32,7 @@ from homeassistant.components.mqtt.climate import (
MQTT_CLIMATE_ATTRIBUTES_BLOCKED, MQTT_CLIMATE_ATTRIBUTES_BLOCKED,
VALUE_TEMPLATE_KEYS, VALUE_TEMPLATE_KEYS,
) )
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
@ -245,11 +245,11 @@ async def test_set_operation_pessimistic(
await mqtt_mock_entry() await mqtt_mock_entry()
state = hass.states.get(ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE)
assert state.state == "unknown" assert state.state == STATE_UNKNOWN
await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE)
state = hass.states.get(ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE)
assert state.state == "unknown" assert state.state == STATE_UNKNOWN
async_fire_mqtt_message(hass, "mode-state", "cool") async_fire_mqtt_message(hass, "mode-state", "cool")
state = hass.states.get(ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE)
@ -259,6 +259,16 @@ async def test_set_operation_pessimistic(
state = hass.states.get(ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE)
assert state.state == "cool" assert state.state == "cool"
# Ignored
async_fire_mqtt_message(hass, "mode-state", "")
state = hass.states.get(ENTITY_CLIMATE)
assert state.state == "cool"
# Reset with `None`
async_fire_mqtt_message(hass, "mode-state", "None")
state = hass.states.get(ENTITY_CLIMATE)
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",
@ -1011,11 +1021,7 @@ async def test_handle_action_received(
"""Test getting the action received via MQTT.""" """Test getting the action received via MQTT."""
await mqtt_mock_entry() await mqtt_mock_entry()
# Cycle through valid modes and also check for wrong input such as "None" (str(None)) # Cycle through valid modes
async_fire_mqtt_message(hass, "action", "None")
state = hass.states.get(ENTITY_CLIMATE)
hvac_action = state.attributes.get(ATTR_HVAC_ACTION)
assert hvac_action is None
# Redefine actions according to https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action # Redefine actions according to https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action
actions = ["off", "preheating", "heating", "cooling", "drying", "idle", "fan"] actions = ["off", "preheating", "heating", "cooling", "drying", "idle", "fan"]
assert all(elem in actions for elem in HVACAction) assert all(elem in actions for elem in HVACAction)
@ -1025,6 +1031,18 @@ async def test_handle_action_received(
hvac_action = state.attributes.get(ATTR_HVAC_ACTION) hvac_action = state.attributes.get(ATTR_HVAC_ACTION)
assert hvac_action == action assert hvac_action == action
# Check empty payload is ignored (last action == "fan")
async_fire_mqtt_message(hass, "action", "")
state = hass.states.get(ENTITY_CLIMATE)
hvac_action = state.attributes.get(ATTR_HVAC_ACTION)
assert hvac_action == "fan"
# Check "None" payload is resetting the action
async_fire_mqtt_message(hass, "action", "None")
state = hass.states.get(ENTITY_CLIMATE)
hvac_action = state.attributes.get(ATTR_HVAC_ACTION)
assert hvac_action is None
@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG])
async def test_set_preset_mode_optimistic( async def test_set_preset_mode_optimistic(
@ -1170,6 +1188,10 @@ async def test_set_preset_mode_pessimistic(
state = hass.states.get(ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("preset_mode") == "comfort" assert state.attributes.get("preset_mode") == "comfort"
async_fire_mqtt_message(hass, "preset-mode-state", "")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("preset_mode") == "comfort"
async_fire_mqtt_message(hass, "preset-mode-state", "None") async_fire_mqtt_message(hass, "preset-mode-state", "None")
state = hass.states.get(ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("preset_mode") == "none" assert state.attributes.get("preset_mode") == "none"
@ -1449,11 +1471,16 @@ async def test_get_with_templates(
state = hass.states.get(ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("hvac_action") == "cooling" assert state.attributes.get("hvac_action") == "cooling"
# Test ignoring null values # Test ignoring empty values
async_fire_mqtt_message(hass, "action", "null") async_fire_mqtt_message(hass, "action", "")
state = hass.states.get(ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("hvac_action") == "cooling" assert state.attributes.get("hvac_action") == "cooling"
# Test resetting with null values
async_fire_mqtt_message(hass, "action", "null")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("hvac_action") is None
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",

View file

@ -123,6 +123,11 @@ async def test_state_via_state_topic(
state = hass.states.get("cover.test") state = hass.states.get("cover.test")
assert state.state == STATE_OPEN assert state.state == STATE_OPEN
async_fire_mqtt_message(hass, "state-topic", "None")
state = hass.states.get("cover.test")
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",

View file

@ -325,6 +325,11 @@ async def test_setting_device_tracker_value_via_mqtt_message(
state = hass.states.get("device_tracker.test") state = hass.states.get("device_tracker.test")
assert state.state == STATE_NOT_HOME assert state.state == STATE_NOT_HOME
# Test an empty value is ignored and the state is retained
async_fire_mqtt_message(hass, "test-topic", "")
state = hass.states.get("device_tracker.test")
assert state.state == STATE_NOT_HOME
async def test_setting_device_tracker_value_via_mqtt_message_and_template( async def test_setting_device_tracker_value_via_mqtt_message_and_template(
hass: HomeAssistant, hass: HomeAssistant,

View file

@ -148,6 +148,12 @@ async def test_controlling_non_default_state_via_topic(
state = hass.states.get("lock.test") state = hass.states.get("lock.test")
assert state.state is lock_state assert state.state is lock_state
# Empty state is ignored
async_fire_mqtt_message(hass, "state-topic", "")
state = hass.states.get("lock.test")
assert state.state is lock_state
@pytest.mark.parametrize( @pytest.mark.parametrize(
("hass_config", "payload", "lock_state"), ("hass_config", "payload", "lock_state"),

View file

@ -3,6 +3,7 @@
from collections.abc import Generator from collections.abc import Generator
import copy import copy
import json import json
import logging
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import patch
@ -91,11 +92,15 @@ def _test_run_select_setup_params(
async def test_run_select_setup( async def test_run_select_setup(
hass: HomeAssistant, hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
topic: str, topic: str,
) -> None: ) -> None:
"""Test that it fetches the given payload.""" """Test that it fetches the given payload."""
await mqtt_mock_entry() await mqtt_mock_entry()
state = hass.states.get("select.test_select")
assert state.state == STATE_UNKNOWN
async_fire_mqtt_message(hass, topic, "milk") async_fire_mqtt_message(hass, topic, "milk")
await hass.async_block_till_done() await hass.async_block_till_done()
@ -110,6 +115,15 @@ async def test_run_select_setup(
state = hass.states.get("select.test_select") state = hass.states.get("select.test_select")
assert state.state == "beer" assert state.state == "beer"
if caplog.at_level(logging.DEBUG):
async_fire_mqtt_message(hass, topic, "")
await hass.async_block_till_done()
assert "Ignoring empty payload" in caplog.text
state = hass.states.get("select.test_select")
assert state.state == "beer"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",

View file

@ -131,6 +131,11 @@ async def test_state_via_state_topic_no_position(
state = hass.states.get("valve.test") state = hass.states.get("valve.test")
assert state.state == asserted_state assert state.state == asserted_state
async_fire_mqtt_message(hass, "state-topic", "None")
state = hass.states.get("valve.test")
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",
@ -197,6 +202,7 @@ async def test_state_via_state_topic_with_template(
('{"position":100}', STATE_OPEN), ('{"position":100}', STATE_OPEN),
('{"position":50.0}', STATE_OPEN), ('{"position":50.0}', STATE_OPEN),
('{"position":0}', STATE_CLOSED), ('{"position":0}', STATE_CLOSED),
('{"position":null}', STATE_UNKNOWN),
('{"position":"non_numeric"}', STATE_UNKNOWN), ('{"position":"non_numeric"}', STATE_UNKNOWN),
('{"ignored":12}', STATE_UNKNOWN), ('{"ignored":12}', STATE_UNKNOWN),
], ],

View file

@ -25,7 +25,12 @@ from homeassistant.components.water_heater import (
STATE_PERFORMANCE, STATE_PERFORMANCE,
WaterHeaterEntityFeature, WaterHeaterEntityFeature,
) )
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.const import (
ATTR_TEMPERATURE,
STATE_OFF,
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_conversion import TemperatureConverter
@ -200,7 +205,7 @@ async def test_set_operation_pessimistic(
await mqtt_mock_entry() await mqtt_mock_entry()
state = hass.states.get(ENTITY_WATER_HEATER) state = hass.states.get(ENTITY_WATER_HEATER)
assert state.state == "unknown" assert state.state == STATE_UNKNOWN
await common.async_set_operation_mode(hass, "eco", ENTITY_WATER_HEATER) await common.async_set_operation_mode(hass, "eco", ENTITY_WATER_HEATER)
state = hass.states.get(ENTITY_WATER_HEATER) state = hass.states.get(ENTITY_WATER_HEATER)
@ -214,6 +219,16 @@ async def test_set_operation_pessimistic(
state = hass.states.get(ENTITY_WATER_HEATER) state = hass.states.get(ENTITY_WATER_HEATER)
assert state.state == "eco" assert state.state == "eco"
# Empty state ignored
async_fire_mqtt_message(hass, "mode-state", "")
state = hass.states.get(ENTITY_WATER_HEATER)
assert state.state == "eco"
# Test None payload
async_fire_mqtt_message(hass, "mode-state", "None")
state = hass.states.get(ENTITY_WATER_HEATER)
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",