Fix yeelight state when controlled outside of Home Assistant (#56964)

This commit is contained in:
J. Nick Koston 2021-10-02 21:08:31 -10:00 committed by GitHub
parent d0827a9129
commit 1aeab65f56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 201 additions and 156 deletions

View File

@ -36,6 +36,9 @@ from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
STATE_CHANGE_TIME = 0.25 # seconds
DOMAIN = "yeelight"
DATA_YEELIGHT = DOMAIN
DATA_UPDATED = "yeelight_{}_data_updated"
@ -546,6 +549,17 @@ class YeelightScanner:
self._async_stop_scan()
def update_needs_bg_power_workaround(data):
"""Check if a push update needs the bg_power workaround.
Some devices will push the incorrect state for bg_power.
To work around this any time we are pushed an update
with bg_power, we force poll state which will be correct.
"""
return "bg_power" in data
class YeelightDevice:
"""Represents single Yeelight device."""
@ -692,12 +706,18 @@ class YeelightDevice:
await self._async_update_properties()
async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host))
async def _async_forced_update(self, _now):
"""Call a forced update."""
await self.async_update(True)
@callback
def async_update_callback(self, data):
"""Update push from device."""
was_available = self._available
self._available = data.get(KEY_CONNECTED, True)
if self._did_first_update and not was_available and self._available:
if update_needs_bg_power_workaround(data) or (
self._did_first_update and not was_available and self._available
):
# On reconnect the properties may be out of sync
#
# We need to make sure the DEVICE_INITIALIZED dispatcher is setup
@ -708,7 +728,7 @@ class YeelightDevice:
# to be called when async_setup_entry reaches the end of the
# function
#
asyncio.create_task(self.async_update(True))
async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update)
async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host))

View File

@ -1,7 +1,6 @@
"""Light platform support for yeelight."""
from __future__ import annotations
import asyncio
import logging
import math
@ -210,9 +209,6 @@ SERVICE_SCHEMA_SET_AUTO_DELAY_OFF_SCENE = {
}
STATE_CHANGE_TIME = 0.25 # seconds
@callback
def _transitions_config_parser(transitions):
"""Parse transitions config into initialized objects."""
@ -252,13 +248,15 @@ def _async_cmd(func):
# A network error happened, the bulb is likely offline now
self.device.async_mark_unavailable()
self.async_write_ha_state()
exc_message = str(ex) or type(ex)
raise HomeAssistantError(
f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {ex}"
f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}"
) from ex
except BULB_EXCEPTIONS as ex:
# The bulb likely responded but had an error
exc_message = str(ex) or type(ex)
raise HomeAssistantError(
f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {ex}"
f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}"
) from ex
return _async_wrap
@ -762,11 +760,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity):
if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb):
await self.async_set_default()
# Some devices (mainly nightlights) will not send back the on state so we need to force a refresh
await asyncio.sleep(STATE_CHANGE_TIME)
if not self.is_on:
await self.device.async_update(True)
@_async_cmd
async def _async_turn_off(self, duration) -> None:
"""Turn off with a given transition duration wrapped with _async_cmd."""
@ -782,10 +775,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity):
duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s
await self._async_turn_off(duration)
# Some devices will not send back the off state so we need to force a refresh
await asyncio.sleep(STATE_CHANGE_TIME)
if self.is_on:
await self.device.async_update(True)
@_async_cmd
async def async_set_mode(self, mode: str):
@ -850,10 +839,8 @@ class YeelightNightLightSupport:
return PowerMode.NORMAL
class YeelightColorLightWithoutNightlightSwitch(
YeelightColorLightSupport, YeelightGenericLight
):
"""Representation of a Color Yeelight light."""
class YeelightWithoutNightlightSwitchMixIn:
"""A mix-in for yeelights without a nightlight switch."""
@property
def _brightness_property(self):
@ -861,9 +848,25 @@ class YeelightColorLightWithoutNightlightSwitch(
# want to "current_brightness" since it will check
# "bg_power" and main light could still be on
if self.device.is_nightlight_enabled:
return "current_brightness"
return "nl_br"
return super()._brightness_property
@property
def color_temp(self) -> int:
"""Return the color temperature."""
if self.device.is_nightlight_enabled:
# Enabling the nightlight locks the colortemp to max
return self._max_mireds
return super().color_temp
class YeelightColorLightWithoutNightlightSwitch(
YeelightColorLightSupport,
YeelightWithoutNightlightSwitchMixIn,
YeelightGenericLight,
):
"""Representation of a Color Yeelight light."""
class YeelightColorLightWithNightlightSwitch(
YeelightNightLightSupport, YeelightColorLightSupport, YeelightGenericLight
@ -880,19 +883,12 @@ class YeelightColorLightWithNightlightSwitch(
class YeelightWhiteTempWithoutNightlightSwitch(
YeelightWhiteTempLightSupport, YeelightGenericLight
YeelightWhiteTempLightSupport,
YeelightWithoutNightlightSwitchMixIn,
YeelightGenericLight,
):
"""White temp light, when nightlight switch is not set to light."""
@property
def _brightness_property(self):
# If the nightlight is not active, we do not
# want to "current_brightness" since it will check
# "bg_power" and main light could still be on
if self.device.is_nightlight_enabled:
return "current_brightness"
return super()._brightness_property
class YeelightWithNightLight(
YeelightNightLightSupport, YeelightWhiteTempLightSupport, YeelightGenericLight
@ -911,6 +907,9 @@ class YeelightWithNightLight(
class YeelightNightLightMode(YeelightGenericLight):
"""Representation of a Yeelight when in nightlight mode."""
_attr_color_mode = COLOR_MODE_BRIGHTNESS
_attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS}
@property
def unique_id(self) -> str:
"""Return a unique ID."""
@ -941,8 +940,9 @@ class YeelightNightLightMode(YeelightGenericLight):
return PowerMode.MOONLIGHT
@property
def _predefined_effects(self):
return YEELIGHT_TEMP_ONLY_EFFECT_LIST
def supported_features(self):
"""Flag no supported features."""
return 0
class YeelightNightLightModeWithAmbientSupport(YeelightNightLightMode):
@ -962,11 +962,6 @@ class YeelightNightLightModeWithoutBrightnessControl(YeelightNightLightMode):
_attr_color_mode = COLOR_MODE_ONOFF
_attr_supported_color_modes = {COLOR_MODE_ONOFF}
@property
def supported_features(self):
"""Flag no supported features."""
return 0
class YeelightWithAmbientWithoutNightlight(YeelightWhiteTempWithoutNightlightSwitch):
"""Representation of a Yeelight which has ambilight support.

View File

@ -13,6 +13,7 @@ from homeassistant.components.yeelight import (
DATA_DEVICE,
DOMAIN,
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
STATE_CHANGE_TIME,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
@ -458,6 +459,8 @@ async def test_connection_dropped_resyncs_properties(hass: HomeAssistant):
await hass.async_block_till_done()
assert len(mocked_bulb.async_get_properties.mock_calls) == 1
mocked_bulb._async_callback({KEY_CONNECTED: True})
await hass.async_block_till_done()
async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=STATE_CHANGE_TIME)
)
await hass.async_block_till_done()
assert len(mocked_bulb.async_get_properties.mock_calls) == 2

View File

@ -545,7 +545,8 @@ async def test_update_errors(hass: HomeAssistant, caplog):
# Timeout usually means the bulb is overloaded with commands
# but will still respond eventually.
mocked_bulb.async_get_properties = AsyncMock(side_effect=asyncio.TimeoutError)
mocked_bulb.async_turn_off = AsyncMock(side_effect=asyncio.TimeoutError)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"light",
SERVICE_TURN_OFF,
@ -557,7 +558,8 @@ async def test_update_errors(hass: HomeAssistant, caplog):
# socket.error usually means the bulb dropped the connection
# or lost wifi, then came back online and forced the existing
# connection closed with a TCP RST
mocked_bulb.async_get_properties = AsyncMock(side_effect=socket.error)
mocked_bulb.async_turn_off = AsyncMock(side_effect=socket.error)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"light",
SERVICE_TURN_OFF,
@ -572,6 +574,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant):
mocked_bulb = _mocked_bulb()
properties = {**PROPERTIES}
properties.pop("active_mode")
properties.pop("nl_br")
properties["color_mode"] = "3" # HSV
mocked_bulb.last_properties = properties
mocked_bulb.bulb_type = BulbType.Color
@ -579,7 +582,9 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant):
domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}
)
config_entry.add_to_hass(hass)
with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb):
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
@ -623,7 +628,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant):
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: ENTITY_LIGHT,
ATTR_BRIGHTNESS_PCT: PROPERTIES["current_brightness"],
ATTR_BRIGHTNESS_PCT: PROPERTIES["bright"],
},
blocking=True,
)
@ -696,9 +701,10 @@ async def test_device_types(hass: HomeAssistant, caplog):
bulb_type,
model,
target_properties,
nightlight_properties=None,
nightlight_entity_properties=None,
name=UNIQUE_FRIENDLY_NAME,
entity_id=ENTITY_LIGHT,
nightlight_mode_properties=None,
):
config_entry = MockConfigEntry(
domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}
@ -708,6 +714,9 @@ async def test_device_types(hass: HomeAssistant, caplog):
mocked_bulb.bulb_type = bulb_type
model_specs = _MODEL_SPECS.get(model)
type(mocked_bulb).get_model_specs = MagicMock(return_value=model_specs)
original_nightlight_brightness = mocked_bulb.last_properties["nl_br"]
mocked_bulb.last_properties["nl_br"] = "0"
await _async_setup(config_entry)
state = hass.states.get(entity_id)
@ -715,18 +724,36 @@ async def test_device_types(hass: HomeAssistant, caplog):
assert state.state == "on"
target_properties["friendly_name"] = name
target_properties["flowing"] = False
target_properties["night_light"] = True
target_properties["night_light"] = False
target_properties["music_mode"] = False
assert dict(state.attributes) == target_properties
await hass.config_entries.async_unload(config_entry.entry_id)
await config_entry.async_remove(hass)
registry = er.async_get(hass)
registry.async_clear_config_entry(config_entry.entry_id)
mocked_bulb.last_properties["nl_br"] = original_nightlight_brightness
# nightlight
if nightlight_properties is None:
return
# nightlight as a setting of the main entity
if nightlight_mode_properties is not None:
mocked_bulb.last_properties["active_mode"] = True
config_entry.add_to_hass(hass)
await _async_setup(config_entry)
state = hass.states.get(entity_id)
assert state.state == "on"
nightlight_mode_properties["friendly_name"] = name
nightlight_mode_properties["flowing"] = False
nightlight_mode_properties["night_light"] = True
nightlight_mode_properties["music_mode"] = False
assert dict(state.attributes) == nightlight_mode_properties
await hass.config_entries.async_unload(config_entry.entry_id)
await config_entry.async_remove(hass)
registry.async_clear_config_entry(config_entry.entry_id)
await hass.async_block_till_done()
mocked_bulb.last_properties.pop("active_mode")
# nightlight as a separate entity
if nightlight_entity_properties is not None:
config_entry = MockConfigEntry(
domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: True}
)
@ -736,12 +763,12 @@ async def test_device_types(hass: HomeAssistant, caplog):
assert hass.states.get(entity_id).state == "off"
state = hass.states.get(f"{entity_id}_nightlight")
assert state.state == "on"
nightlight_properties["friendly_name"] = f"{name} Nightlight"
nightlight_properties["icon"] = "mdi:weather-night"
nightlight_properties["flowing"] = False
nightlight_properties["night_light"] = True
nightlight_properties["music_mode"] = False
assert dict(state.attributes) == nightlight_properties
nightlight_entity_properties["friendly_name"] = f"{name} Nightlight"
nightlight_entity_properties["icon"] = "mdi:weather-night"
nightlight_entity_properties["flowing"] = False
nightlight_entity_properties["night_light"] = True
nightlight_entity_properties["music_mode"] = False
assert dict(state.attributes) == nightlight_entity_properties
await hass.config_entries.async_unload(config_entry.entry_id)
await config_entry.async_remove(hass)
@ -749,7 +776,6 @@ async def test_device_types(hass: HomeAssistant, caplog):
await hass.async_block_till_done()
bright = round(255 * int(PROPERTIES["bright"]) / 100)
current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100)
ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"]))
hue = int(PROPERTIES["hue"])
sat = int(PROPERTIES["sat"])
@ -806,7 +832,7 @@ async def test_device_types(hass: HomeAssistant, caplog):
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": current_brightness,
"brightness": bright,
"color_temp": ct,
"color_mode": "color_temp",
"supported_color_modes": ["color_temp", "hs", "rgb"],
@ -814,11 +840,30 @@ async def test_device_types(hass: HomeAssistant, caplog):
"rgb_color": (255, 205, 166),
"xy_color": (0.421, 0.364),
},
{
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
},
nightlight_mode_properties={
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"supported_features": SUPPORT_YEELIGHT,
"hs_color": (28.401, 100.0),
"rgb_color": (255, 120, 0),
"xy_color": (0.621, 0.367),
"min_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["max"]
),
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": nl_br,
"color_mode": "color_temp",
"supported_color_modes": ["color_temp", "hs", "rgb"],
"color_temp": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
},
)
# Color - color mode HS
@ -836,14 +881,14 @@ async def test_device_types(hass: HomeAssistant, caplog):
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": current_brightness,
"brightness": bright,
"hs_color": hs_color,
"rgb_color": color_hs_to_RGB(*hs_color),
"xy_color": color_hs_to_xy(*hs_color),
"color_mode": "hs",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
{
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
@ -865,14 +910,14 @@ async def test_device_types(hass: HomeAssistant, caplog):
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": current_brightness,
"brightness": bright,
"hs_color": color_RGB_to_hs(*rgb_color),
"rgb_color": rgb_color,
"xy_color": color_RGB_to_xy(*rgb_color),
"color_mode": "rgb",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
{
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
@ -895,11 +940,11 @@ async def test_device_types(hass: HomeAssistant, caplog):
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": current_brightness,
"brightness": bright,
"color_mode": "hs",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
{
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
@ -922,11 +967,11 @@ async def test_device_types(hass: HomeAssistant, caplog):
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": current_brightness,
"brightness": bright,
"color_mode": "rgb",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
{
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
@ -973,7 +1018,7 @@ async def test_device_types(hass: HomeAssistant, caplog):
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": current_brightness,
"brightness": bright,
"color_temp": ct,
"color_mode": "color_temp",
"supported_color_modes": ["color_temp"],
@ -981,13 +1026,31 @@ async def test_device_types(hass: HomeAssistant, caplog):
"rgb_color": (255, 205, 166),
"xy_color": (0.421, 0.364),
},
{
"effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST,
"supported_features": SUPPORT_YEELIGHT,
nightlight_entity_properties={
"supported_features": 0,
"brightness": nl_br,
"color_mode": "brightness",
"supported_color_modes": ["brightness"],
},
nightlight_mode_properties={
"effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST,
"supported_features": SUPPORT_YEELIGHT,
"min_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["max"]
),
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": nl_br,
"color_temp": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"color_mode": "color_temp",
"supported_color_modes": ["color_temp"],
"hs_color": (28.391, 65.659),
"rgb_color": (255, 166, 87),
"xy_color": (0.526, 0.387),
},
)
# WhiteTempMood
@ -1009,7 +1072,7 @@ async def test_device_types(hass: HomeAssistant, caplog):
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": current_brightness,
"brightness": bright,
"color_temp": ct,
"color_mode": "color_temp",
"supported_color_modes": ["color_temp"],
@ -1017,13 +1080,34 @@ async def test_device_types(hass: HomeAssistant, caplog):
"rgb_color": (255, 205, 166),
"xy_color": (0.421, 0.364),
},
{
"effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST,
"supported_features": SUPPORT_YEELIGHT,
nightlight_entity_properties={
"supported_features": 0,
"brightness": nl_br,
"color_mode": "brightness",
"supported_color_modes": ["brightness"],
},
nightlight_mode_properties={
"friendly_name": NAME,
"effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST,
"flowing": False,
"night_light": True,
"supported_features": SUPPORT_YEELIGHT,
"min_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["max"]
),
"max_mireds": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"brightness": nl_br,
"color_temp": color_temperature_kelvin_to_mired(
model_specs["color_temp"]["min"]
),
"color_mode": "color_temp",
"supported_color_modes": ["color_temp"],
"hs_color": (28.391, 65.659),
"rgb_color": (255, 166, 87),
"xy_color": (0.526, 0.387),
},
)
# Background light - color mode CT
mocked_bulb.last_properties["bg_lmode"] = "2" # CT
@ -1261,62 +1345,6 @@ async def test_effects(hass: HomeAssistant):
await _async_test_effect("not_existed", called=False)
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(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()
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
assert len(mocked_bulb.async_get_properties.mock_calls) == 2
mocked_bulb.last_properties["power"] = "on"
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) == 1
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
assert len(mocked_bulb.async_get_properties.mock_calls) == 3
async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant):
"""Test that main light on ambilights with the nightlight disabled shows the correct brightness."""
mocked_bulb = _mocked_bulb()
@ -1325,7 +1353,6 @@ async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant):
capabilities["model"] = "ceiling10"
properties["color_mode"] = "3" # HSV
properties["bg_power"] = "off"
properties["current_brightness"] = 0
properties["bg_lmode"] = "2" # CT
mocked_bulb.last_properties = properties
mocked_bulb.bulb_type = BulbType.WhiteTempMood