Restore yeelight workaround for failing to update state after on/off (#57400)
This commit is contained in:
parent
45b60b8346
commit
a58085639e
3 changed files with 116 additions and 5 deletions
|
@ -36,8 +36,8 @@ from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
STATE_CHANGE_TIME = 0.25 # seconds
|
STATE_CHANGE_TIME = 0.40 # seconds
|
||||||
|
POWER_STATE_CHANGE_TIME = 1 # seconds
|
||||||
|
|
||||||
DOMAIN = "yeelight"
|
DOMAIN = "yeelight"
|
||||||
DATA_YEELIGHT = DOMAIN
|
DATA_YEELIGHT = DOMAIN
|
||||||
|
|
|
@ -38,6 +38,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_platform
|
from homeassistant.helpers import entity_platform
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
from homeassistant.helpers.event import async_call_later
|
||||||
import homeassistant.util.color as color_util
|
import homeassistant.util.color as color_util
|
||||||
from homeassistant.util.color import (
|
from homeassistant.util.color import (
|
||||||
color_temperature_kelvin_to_mired as kelvin_to_mired,
|
color_temperature_kelvin_to_mired as kelvin_to_mired,
|
||||||
|
@ -62,6 +63,7 @@ from . import (
|
||||||
DATA_DEVICE,
|
DATA_DEVICE,
|
||||||
DATA_UPDATED,
|
DATA_UPDATED,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
POWER_STATE_CHANGE_TIME,
|
||||||
YEELIGHT_FLOW_TRANSITION_SCHEMA,
|
YEELIGHT_FLOW_TRANSITION_SCHEMA,
|
||||||
YeelightEntity,
|
YeelightEntity,
|
||||||
)
|
)
|
||||||
|
@ -247,7 +249,7 @@ def _async_cmd(func):
|
||||||
except BULB_NETWORK_EXCEPTIONS as ex:
|
except BULB_NETWORK_EXCEPTIONS as ex:
|
||||||
# A network error happened, the bulb is likely offline now
|
# A network error happened, the bulb is likely offline now
|
||||||
self.device.async_mark_unavailable()
|
self.device.async_mark_unavailable()
|
||||||
self.async_write_ha_state()
|
self.async_state_changed()
|
||||||
exc_message = str(ex) or type(ex)
|
exc_message = str(ex) or type(ex)
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}"
|
f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}"
|
||||||
|
@ -419,13 +421,22 @@ class YeelightGenericLight(YeelightEntity, LightEntity):
|
||||||
else:
|
else:
|
||||||
self._custom_effects = {}
|
self._custom_effects = {}
|
||||||
|
|
||||||
|
self._unexpected_state_check = None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_state_changed(self):
|
||||||
|
"""Call when the device changes state."""
|
||||||
|
if not self._device.available:
|
||||||
|
self._async_cancel_pending_state_check()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
"""Handle entity which will be added."""
|
"""Handle entity which will be added."""
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
self.hass,
|
self.hass,
|
||||||
DATA_UPDATED.format(self._device.host),
|
DATA_UPDATED.format(self._device.host),
|
||||||
self.async_write_ha_state,
|
self.async_state_changed,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
@ -760,6 +771,33 @@ class YeelightGenericLight(YeelightEntity, LightEntity):
|
||||||
if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb):
|
if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb):
|
||||||
await self.async_set_default()
|
await self.async_set_default()
|
||||||
|
|
||||||
|
self._async_schedule_state_check(True)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_cancel_pending_state_check(self):
|
||||||
|
"""Cancel a pending state check."""
|
||||||
|
if self._unexpected_state_check:
|
||||||
|
self._unexpected_state_check()
|
||||||
|
self._unexpected_state_check = None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_schedule_state_check(self, expected_power_state):
|
||||||
|
"""Schedule a poll if the change failed to get pushed back to us.
|
||||||
|
|
||||||
|
Some devices (mainly nightlights) will not send back the on state
|
||||||
|
so we need to force a refresh.
|
||||||
|
"""
|
||||||
|
self._async_cancel_pending_state_check()
|
||||||
|
|
||||||
|
async def _async_update_if_state_unexpected(*_):
|
||||||
|
self._unexpected_state_check = None
|
||||||
|
if self.is_on != expected_power_state:
|
||||||
|
await self.device.async_update(True)
|
||||||
|
|
||||||
|
self._unexpected_state_check = async_call_later(
|
||||||
|
self.hass, POWER_STATE_CHANGE_TIME, _async_update_if_state_unexpected
|
||||||
|
)
|
||||||
|
|
||||||
@_async_cmd
|
@_async_cmd
|
||||||
async def _async_turn_off(self, duration) -> None:
|
async def _async_turn_off(self, duration) -> None:
|
||||||
"""Turn off with a given transition duration wrapped with _async_cmd."""
|
"""Turn off with a given transition duration wrapped with _async_cmd."""
|
||||||
|
@ -775,6 +813,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity):
|
||||||
duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s
|
duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s
|
||||||
|
|
||||||
await self._async_turn_off(duration)
|
await self._async_turn_off(duration)
|
||||||
|
self._async_schedule_state_check(False)
|
||||||
|
|
||||||
@_async_cmd
|
@_async_cmd
|
||||||
async def async_set_mode(self, mode: str):
|
async def async_set_mode(self, mode: str):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""Test the Yeelight light."""
|
"""Test the Yeelight light."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
|
||||||
|
@ -98,6 +99,7 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.util.color import (
|
from homeassistant.util.color import (
|
||||||
color_hs_to_RGB,
|
color_hs_to_RGB,
|
||||||
color_hs_to_xy,
|
color_hs_to_xy,
|
||||||
|
@ -121,7 +123,7 @@ from . import (
|
||||||
_patch_discovery_interval,
|
_patch_discovery_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
|
||||||
CONFIG_ENTRY_DATA = {
|
CONFIG_ENTRY_DATA = {
|
||||||
CONF_HOST: IP_ADDRESS,
|
CONF_HOST: IP_ADDRESS,
|
||||||
|
@ -1377,3 +1379,73 @@ async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant):
|
||||||
assert state.state == "on"
|
assert state.state == "on"
|
||||||
# bg_power off should not set the brightness to 0
|
# bg_power off should not set the brightness to 0
|
||||||
assert state.attributes[ATTR_BRIGHTNESS] == 128
|
assert state.attributes[ATTR_BRIGHTNESS] == 128
|
||||||
|
|
||||||
|
|
||||||
|
async def test_state_fails_to_update_triggers_update(hass: HomeAssistant):
|
||||||
|
"""Ensure we call async_get_properties if the turn on/off fails to update the state."""
|
||||||
|
mocked_bulb = _mocked_bulb()
|
||||||
|
properties = {**PROPERTIES}
|
||||||
|
properties.pop("active_mode")
|
||||||
|
properties["color_mode"] = "3" # HSV
|
||||||
|
mocked_bulb.last_properties = properties
|
||||||
|
mocked_bulb.bulb_type = BulbType.Color
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
with _patch_discovery(), _patch_discovery_interval(), patch(
|
||||||
|
f"{MODULE}.AsyncBulb", return_value=mocked_bulb
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
# We use asyncio.create_task now to avoid
|
||||||
|
# blocking starting so we need to block again
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(mocked_bulb.async_get_properties.mock_calls) == 1
|
||||||
|
|
||||||
|
mocked_bulb.last_properties["power"] = "off"
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light",
|
||||||
|
SERVICE_TURN_ON,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: ENTITY_LIGHT,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert len(mocked_bulb.async_turn_on.mock_calls) == 1
|
||||||
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mocked_bulb.async_get_properties.mock_calls) == 2
|
||||||
|
|
||||||
|
mocked_bulb.last_properties["power"] = "on"
|
||||||
|
for _ in range(5):
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light",
|
||||||
|
SERVICE_TURN_OFF,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: ENTITY_LIGHT,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert len(mocked_bulb.async_turn_off.mock_calls) == 5
|
||||||
|
# Even with five calls we only do one state request
|
||||||
|
# since each successive call should cancel the unexpected
|
||||||
|
# state check
|
||||||
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mocked_bulb.async_get_properties.mock_calls) == 3
|
||||||
|
|
||||||
|
# But if the state is correct no calls
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light",
|
||||||
|
SERVICE_TURN_ON,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: ENTITY_LIGHT,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert len(mocked_bulb.async_turn_on.mock_calls) == 1
|
||||||
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mocked_bulb.async_get_properties.mock_calls) == 3
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue