Reconnect and retry yeelight commands after previous wifi drop out (#57741)

This commit is contained in:
J. Nick Koston 2021-10-15 06:37:13 -10:00 committed by GitHub
parent 5aba8a7c81
commit 427f2a085b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 92 additions and 29 deletions

View file

@ -6,7 +6,6 @@ import contextlib
from datetime import timedelta from datetime import timedelta
from ipaddress import IPv4Address, IPv6Address from ipaddress import IPv4Address, IPv6Address
import logging import logging
import socket
from urllib.parse import urlparse from urllib.parse import urlparse
from async_upnp_client.search import SsdpSearchListener from async_upnp_client.search import SsdpSearchListener
@ -163,9 +162,6 @@ UPDATE_REQUEST_PROPERTIES = [
"active_mode", "active_mode",
] ]
BULB_NETWORK_EXCEPTIONS = (socket.error,)
BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError, *BULB_NETWORK_EXCEPTIONS)
PLATFORMS = ["binary_sensor", "light"] PLATFORMS = ["binary_sensor", "light"]
@ -270,7 +266,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
device = await _async_get_device(hass, entry.data[CONF_HOST], entry) device = await _async_get_device(hass, entry.data[CONF_HOST], entry)
await _async_initialize(hass, entry, device) await _async_initialize(hass, entry, device)
except BULB_EXCEPTIONS as ex: except (asyncio.TimeoutError, OSError, BulbException) as ex:
raise ConfigEntryNotReady from ex raise ConfigEntryNotReady from ex
hass.config_entries.async_setup_platforms(entry, PLATFORMS) hass.config_entries.async_setup_platforms(entry, PLATFORMS)
@ -594,13 +590,20 @@ class YeelightDevice:
self._available = True self._available = True
if not self._initialized: if not self._initialized:
self._initialized = True self._initialized = True
except BULB_NETWORK_EXCEPTIONS as ex: except OSError as ex:
if self._available: # just inform once if self._available: # just inform once
_LOGGER.error( _LOGGER.error(
"Unable to update device %s, %s: %s", self._host, self.name, ex "Unable to update device %s, %s: %s", self._host, self.name, ex
) )
self._available = False self._available = False
except BULB_EXCEPTIONS as ex: except asyncio.TimeoutError as ex:
_LOGGER.debug(
"timed out while trying to update device %s, %s: %s",
self._host,
self.name,
ex,
)
except BulbException as ex:
_LOGGER.debug( _LOGGER.debug(
"Unable to update device %s, %s: %s", self._host, self.name, ex "Unable to update device %s, %s: %s", self._host, self.name, ex
) )

View file

@ -1,6 +1,7 @@
"""Light platform support for yeelight.""" """Light platform support for yeelight."""
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import math import math
@ -8,6 +9,7 @@ import voluptuous as vol
import yeelight import yeelight
from yeelight import Bulb, Flow, RGBTransition, SleepTransition, flows from yeelight import Bulb, Flow, RGBTransition, SleepTransition, flows
from yeelight.enums import BulbType, LightType, PowerMode, SceneClass from yeelight.enums import BulbType, LightType, PowerMode, SceneClass
from yeelight.main import BulbException
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -51,8 +53,6 @@ from . import (
ATTR_COUNT, ATTR_COUNT,
ATTR_MODE_MUSIC, ATTR_MODE_MUSIC,
ATTR_TRANSITIONS, ATTR_TRANSITIONS,
BULB_EXCEPTIONS,
BULB_NETWORK_EXCEPTIONS,
CONF_FLOW_PARAMS, CONF_FLOW_PARAMS,
CONF_MODE_MUSIC, CONF_MODE_MUSIC,
CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH,
@ -243,10 +243,20 @@ def _async_cmd(func):
"""Define a wrapper to catch exceptions from the bulb.""" """Define a wrapper to catch exceptions from the bulb."""
async def _async_wrap(self, *args, **kwargs): async def _async_wrap(self, *args, **kwargs):
for attempts in range(2):
try: try:
_LOGGER.debug("Calling %s with %s %s", func, args, kwargs) _LOGGER.debug("Calling %s with %s %s", func, args, kwargs)
return await func(self, *args, **kwargs) return await func(self, *args, **kwargs)
except BULB_NETWORK_EXCEPTIONS as ex: except asyncio.TimeoutError as ex:
# The wifi likely dropped, so we want to retry once since
# python-yeelight will auto reconnect
exc_message = str(ex) or type(ex)
if attempts == 0:
continue
raise HomeAssistantError(
f"Timed out when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}"
) from ex
except OSError 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_state_changed() self.async_state_changed()
@ -254,7 +264,7 @@ def _async_cmd(func):
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}"
) from ex ) from ex
except BULB_EXCEPTIONS as ex: except BulbException as ex:
# The bulb likely responded but had an error # The bulb likely responded but had an error
exc_message = str(ex) or type(ex) exc_message = str(ex) or type(ex)
raise HomeAssistantError( raise HomeAssistantError(

View file

@ -2,7 +2,7 @@
"domain": "yeelight", "domain": "yeelight",
"name": "Yeelight", "name": "Yeelight",
"documentation": "https://www.home-assistant.io/integrations/yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight",
"requirements": ["yeelight==0.7.7", "async-upnp-client==0.22.8"], "requirements": ["yeelight==0.7.8", "async-upnp-client==0.22.8"],
"codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"],
"config_flow": true, "config_flow": true,
"dependencies": ["network"], "dependencies": ["network"],

View file

@ -2447,7 +2447,7 @@ yalesmartalarmclient==0.3.4
yalexs==1.1.13 yalexs==1.1.13
# homeassistant.components.yeelight # homeassistant.components.yeelight
yeelight==0.7.7 yeelight==0.7.8
# homeassistant.components.yeelightsunflower # homeassistant.components.yeelightsunflower
yeelightsunflower==0.0.10 yeelightsunflower==0.0.10

View file

@ -1412,7 +1412,7 @@ yalesmartalarmclient==0.3.4
yalexs==1.1.13 yalexs==1.1.13
# homeassistant.components.yeelight # homeassistant.components.yeelight
yeelight==0.7.7 yeelight==0.7.8
# homeassistant.components.youless # homeassistant.components.youless
youless-api==0.14 youless-api==0.14

View file

@ -1,7 +1,9 @@
"""Test Yeelight.""" """Test Yeelight."""
import asyncio
from datetime import timedelta from datetime import timedelta
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest
from yeelight import BulbException, BulbType from yeelight import BulbException, BulbType
from yeelight.aio import KEY_CONNECTED from yeelight.aio import KEY_CONNECTED
@ -507,3 +509,51 @@ async def test_connection_dropped_resyncs_properties(hass: HomeAssistant):
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mocked_bulb.async_get_properties.mock_calls) == 2 assert len(mocked_bulb.async_get_properties.mock_calls) == 2
async def test_oserror_on_first_update_results_in_unavailable(hass: HomeAssistant):
"""Test that an OSError on first update results in unavailable."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=ID,
data={CONF_HOST: "127.0.0.1"},
options={CONF_NAME: "Test name"},
)
config_entry.add_to_hass(hass)
mocked_bulb = _mocked_bulb()
mocked_bulb.async_get_properties = AsyncMock(side_effect=OSError)
with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch(
f"{MODULE}.AsyncBulb", return_value=mocked_bulb
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("light.test_name").state == STATE_UNAVAILABLE
@pytest.mark.parametrize("exception", [BulbException, asyncio.TimeoutError])
async def test_non_oserror_exception_on_first_update(
hass: HomeAssistant, exception: Exception
):
"""Test that an exceptions other than OSError on first update do not result in unavailable.
The unavailable state will come as a push update in this case
"""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=ID,
data={CONF_HOST: "127.0.0.1"},
options={CONF_NAME: "Test name"},
)
config_entry.add_to_hass(hass)
mocked_bulb = _mocked_bulb()
mocked_bulb.async_get_properties = AsyncMock(side_effect=exception)
with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch(
f"{MODULE}.AsyncBulb", return_value=mocked_bulb
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("light.test_name").state != STATE_UNAVAILABLE