Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically retry lost/timed out LIFX requests #91157

Merged
merged 30 commits into from
Apr 16, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
aac10fc
Automatically retry failed LIFX requests
bdraco Apr 10, 2023
42722f0
Update homeassistant/components/lifx/coordinator.py
bdraco Apr 10, 2023
a5db9bc
lint
bdraco Apr 10, 2023
984d603
set max time for update to happen to match previous UNAVAILABLE_GRACE…
bdraco Apr 10, 2023
9e5717d
handle timeout
bdraco Apr 10, 2023
d094327
avoid retry timeouts in tests
bdraco Apr 10, 2023
c11a6ba
friendly error
bdraco Apr 10, 2023
1bb35c2
friendly error
bdraco Apr 10, 2023
fad96ea
friendly error
bdraco Apr 10, 2023
838415d
friendly error
bdraco Apr 10, 2023
e96e426
Update homeassistant/components/lifx/const.py
bdraco Apr 10, 2023
7c51eda
Update homeassistant/components/lifx/const.py
bdraco Apr 10, 2023
f673974
Update homeassistant/components/lifx/const.py
bdraco Apr 10, 2023
71d1a90
Update homeassistant/components/lifx/const.py
bdraco Apr 10, 2023
2368e5d
Update homeassistant/components/lifx/const.py
bdraco Apr 10, 2023
e6a1054
Update homeassistant/components/lifx/coordinator.py
bdraco Apr 10, 2023
ac5d39b
Update homeassistant/components/lifx/coordinator.py
bdraco Apr 10, 2023
13cb9eb
Update homeassistant/components/lifx/coordinator.py
bdraco Apr 10, 2023
8424d00
Update homeassistant/components/lifx/coordinator.py
bdraco Apr 10, 2023
7051e0c
Update homeassistant/components/lifx/coordinator.py
bdraco Apr 10, 2023
f3e3842
Update homeassistant/components/lifx/coordinator.py
bdraco Apr 10, 2023
066ab9f
tweak
bdraco Apr 10, 2023
8f789b6
Update homeassistant/components/lifx/coordinator.py
bdraco Apr 10, 2023
5cfc11c
tweak
bdraco Apr 10, 2023
8ba8703
tweak
bdraco Apr 10, 2023
b17fded
tweak
bdraco Apr 10, 2023
e8d7052
tweak
bdraco Apr 10, 2023
1de5d13
Merge remote-tracking branch 'upstream/lifx_retries' into lifx_retries
bdraco Apr 10, 2023
a84b5af
avoid future refactoring error risk
bdraco Apr 10, 2023
ece8dd2
Merge branch 'dev' into lifx_retries
bdraco Apr 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions homeassistant/components/lifx/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.typing import DiscoveryInfoType

from .const import _LOGGER, CONF_SERIAL, DOMAIN, TARGET_ANY
from .const import (
_LOGGER,
CONF_SERIAL,
DEFAULT_ATTEMPTS,
DOMAIN,
OVERALL_TIMEOUT,
TARGET_ANY,
)
from .discovery import async_discover_devices
from .util import (
async_entry_is_legacy,
async_execute_lifx,
async_get_legacy_entry,
async_multi_execute_lifx_with_retries,
formatted_serial,
lifx_features,
mac_matches_serial_number,
Expand Down Expand Up @@ -225,13 +232,15 @@ async def _async_try_connect(
# get_version required for lifx_features()
# get_label required to log the name of the device
# get_group required to populate suggested areas
messages = await asyncio.gather(
*[
async_execute_lifx(device.get_hostfirmware),
async_execute_lifx(device.get_version),
async_execute_lifx(device.get_label),
async_execute_lifx(device.get_group),
]
messages = await async_multi_execute_lifx_with_retries(
[
device.get_hostfirmware,
device.get_version,
device.get_label,
device.get_group,
],
DEFAULT_ATTEMPTS,
OVERALL_TIMEOUT,
)
except asyncio.TimeoutError:
return None
Expand Down
14 changes: 11 additions & 3 deletions homeassistant/components/lifx/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@
TARGET_ANY = "00:00:00:00:00:00"

DISCOVERY_INTERVAL = 10
MESSAGE_TIMEOUT = 1.65
MESSAGE_RETRIES = 5
OVERALL_TIMEOUT = 9
MESSAGE_TIMEOUT = 18
MESSAGE_RETRIES = 1
OVERALL_TIMEOUT = 15
UNAVAILABLE_GRACE = 90

# The number of times to retry a request message
DEFAULT_ATTEMPTS = 5
# The maximum time to wait for a bulb to respond to an update
MAX_UPDATE_TIME = 90
# The number of tries to send each request message to a bulb during an update
MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE = 5

CONF_LABEL = "label"
CONF_SERIAL = "serial"

Expand Down Expand Up @@ -50,4 +57,5 @@
}
DATA_LIFX_MANAGER = "lifx_manager"


_LOGGER = logging.getLogger(__package__)
147 changes: 81 additions & 66 deletions homeassistant/components/lifx/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,32 @@
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import (
_LOGGER,
ATTR_REMAINING,
DEFAULT_ATTEMPTS,
DOMAIN,
IDENTIFY_WAVEFORM,
MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE,
MAX_UPDATE_TIME,
MESSAGE_RETRIES,
MESSAGE_TIMEOUT,
OVERALL_TIMEOUT,
TARGET_ANY,
UNAVAILABLE_GRACE,
)
from .util import (
async_execute_lifx,
async_multi_execute_lifx_with_retries,
get_real_mac_addr,
infrared_brightness_option_to_value,
infrared_brightness_value_to_option,
lifx_features,
)

LIGHT_UPDATE_INTERVAL = 10
SENSOR_UPDATE_INTERVAL = 30
REQUEST_REFRESH_DELAY = 0.35
LIFX_IDENTIFY_DELAY = 3.0
RSSI_DBM_FW = AwesomeVersion("2.77")
Expand Down Expand Up @@ -186,60 +190,86 @@ def async_get_entity_id(self, platform: Platform, key: str) -> str | None:
platform, DOMAIN, f"{self.serial_number}_{key}"
)

async def _async_update_data(self) -> None:
"""Fetch all device data from the api."""
async with self.lock:
if self.device.host_firmware_version is None:
self.device.get_hostfirmware()
if self.device.product is None:
self.device.get_version()
if self.device.group is None:
self.device.get_group()

response = await async_execute_lifx(self.device.get_color)

if self.device.product is None:
raise UpdateFailed(
f"Failed to fetch get version from device: {self.device.ip_addr}"
)

# device.mac_addr is not the mac_address, its the serial number
if self.device.mac_addr == TARGET_ANY:
self.device.mac_addr = response.target_addr

if self._update_rssi is True:
await self.async_update_rssi()

# Update extended multizone devices
if lifx_features(self.device)["extended_multizone"]:
await self.async_get_extended_color_zones()
await self.async_get_multizone_effect()
# use legacy methods for older devices
elif lifx_features(self.device)["multizone"]:
await self.async_get_color_zones()
await self.async_get_multizone_effect()
async def _async_populate_device_info(self) -> None:
"""Populate device info."""
methods: list[Callable] = []
device = self.device
if self.device.host_firmware_version is None:
methods.append(device.get_hostfirmware)
if self.device.product is None:
methods.append(device.get_version)
if self.device.group is None:
methods.append(device.get_group)
assert methods, "Device info already populated"
await async_multi_execute_lifx_with_retries(
methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT
)

if lifx_features(self.device)["hev"]:
await self.async_get_hev_cycle()
@callback
def _async_build_color_zones_update_requests(self) -> list[Callable]:
"""Build a color zones update request."""
device = self.device
return [
partial(device.get_color_zones, start_index=zone)
for zone in range(0, len(device.color_zones), 8)
]

if lifx_features(self.device)["infrared"]:
await async_execute_lifx(self.device.get_infrared)
async def _async_update_data(self) -> None:
"""Fetch all device data from the api."""
device = self.device
if (
device.host_firmware_version is None
or device.product is None
or device.group is None
):
await self._async_populate_device_info()

num_zones = len(device.color_zones) if device.color_zones is not None else 0
features = lifx_features(self.device)
is_extended_multizone = features["extended_multizone"]
is_legacy_multizone = not is_extended_multizone and features["multizone"]
update_rssi = self._update_rssi
methods: list[Callable] = [self.device.get_color]
if update_rssi:
methods.append(self.device.get_wifiinfo)
if is_extended_multizone:
methods.append(self.device.get_extended_color_zones)
methods.append(self.device.get_multizone_effect)
elif is_legacy_multizone:
methods.append(self.device.get_extended_color_zones)
bdraco marked this conversation as resolved.
Show resolved Hide resolved
bdraco marked this conversation as resolved.
Show resolved Hide resolved
methods.extend(self._async_build_color_zones_update_requests())
bdraco marked this conversation as resolved.
Show resolved Hide resolved
methods.extend(self.device.get_extended_color_zones)
bdraco marked this conversation as resolved.
Show resolved Hide resolved
if features["hev"]:
methods.append(self.device.get_hev_cycle)
if features["infrared"]:
methods.append(self.device.get_infrared)

responses = await async_multi_execute_lifx_with_retries(
methods, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME
)
# device.mac_addr is not the mac_address, its the serial number
if device.mac_addr == TARGET_ANY:
device.mac_addr = responses[0].target_addr

if update_rssi:
# We always send the rssi request second
self._rssi = int(floor(10 * log10(responses[1].signal) + 0.5))

if is_extended_multizone:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")]
bdraco marked this conversation as resolved.
Show resolved Hide resolved
elif is_legacy_multizone and num_zones != len(device.color_zones):
# The number of zones has changed so we need
# to update the zones again. This should only
# happen once after a device is added.
bdraco marked this conversation as resolved.
Show resolved Hide resolved
await self.async_get_color_zones()

async def async_get_color_zones(self) -> None:
"""Get updated color information for each zone."""
zone = 0
top = 1
while zone < top:
# Each get_color_zones can update 8 zones at once
resp = await async_execute_lifx(
partial(self.device.get_color_zones, start_index=zone)
)
zone += 8
top = resp.count

# We only await multizone responses so don't ask for just one
if zone == top - 1:
zone -= 1
await async_multi_execute_lifx_with_retries(
self._async_build_color_zones_update_requests(),
DEFAULT_ATTEMPTS,
OVERALL_TIMEOUT,
)

async def async_get_extended_color_zones(self) -> None:
"""Get updated color information for all zones."""
Expand Down Expand Up @@ -323,11 +353,6 @@ async def async_set_extended_color_zones(
)
)

async def async_get_multizone_effect(self) -> None:
"""Update the device firmware effect running state."""
await async_execute_lifx(self.device.get_multizone_effect)
self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")]

async def async_set_multizone_effect(
self,
effect: str,
Expand Down Expand Up @@ -415,22 +440,12 @@ def _async_disable_rssi_updates() -> None:
self._update_rssi = True
return _async_disable_rssi_updates

async def async_update_rssi(self) -> None:
"""Update RSSI value."""
resp = await async_execute_lifx(self.device.get_wifiinfo)
self._rssi = int(floor(10 * log10(resp.signal) + 0.5))

def async_get_hev_cycle_state(self) -> bool | None:
"""Return the current HEV cycle state."""
if self.device.hev_cycle is None:
return None
return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0)

async def async_get_hev_cycle(self) -> None:
"""Update the HEV cycle status from a LIFX Clean bulb."""
if lifx_features(self.device)["hev"]:
await async_execute_lifx(self.device.get_hev_cycle)

async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None:
"""Start or stop an HEV cycle on a LIFX Clean bulb."""
if lifx_features(self.device)["hev"]:
Expand Down
103 changes: 51 additions & 52 deletions homeassistant/components/lifx/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,61 +206,60 @@ async def async_turn_off(self, **kwargs: Any) -> None:
async def set_state(self, **kwargs: Any) -> None:
"""Set a color on the light and turn it on/off."""
self.coordinator.async_set_updated_data(None)
async with self.coordinator.lock:
# Cancel any pending refreshes
bulb = self.bulb
# Cancel any pending refreshes
bulb = self.bulb

await self.effects_conductor.stop([bulb])
await self.effects_conductor.stop([bulb])

if ATTR_EFFECT in kwargs:
await self.default_effect(**kwargs)
return
if ATTR_EFFECT in kwargs:
await self.default_effect(**kwargs)
return

if ATTR_INFRARED in kwargs:
infrared_entity_id = self.coordinator.async_get_entity_id(
Platform.SELECT, INFRARED_BRIGHTNESS
)
_LOGGER.warning(
(
"The 'infrared' attribute of 'lifx.set_state' is deprecated:"
" call 'select.select_option' targeting '%s' instead"
),
infrared_entity_id,
)
bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED]))

if ATTR_TRANSITION in kwargs:
fade = int(kwargs[ATTR_TRANSITION] * 1000)
else:
fade = 0

# These are both False if ATTR_POWER is not set
power_on = kwargs.get(ATTR_POWER, False)
power_off = not kwargs.get(ATTR_POWER, True)

hsbk = find_hsbk(self.hass, **kwargs)

if not self.is_on:
if power_off:
await self.set_power(False)
# If fading on with color, set color immediately
if hsbk and power_on:
await self.set_color(hsbk, kwargs)
await self.set_power(True, duration=fade)
elif hsbk:
await self.set_color(hsbk, kwargs, duration=fade)
elif power_on:
await self.set_power(True, duration=fade)
else:
if power_on:
await self.set_power(True)
if hsbk:
await self.set_color(hsbk, kwargs, duration=fade)
if power_off:
await self.set_power(False, duration=fade)

# Avoid state ping-pong by holding off updates as the state settles
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)
if ATTR_INFRARED in kwargs:
infrared_entity_id = self.coordinator.async_get_entity_id(
Platform.SELECT, INFRARED_BRIGHTNESS
)
_LOGGER.warning(
(
"The 'infrared' attribute of 'lifx.set_state' is deprecated:"
" call 'select.select_option' targeting '%s' instead"
),
infrared_entity_id,
)
bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED]))

if ATTR_TRANSITION in kwargs:
fade = int(kwargs[ATTR_TRANSITION] * 1000)
else:
fade = 0

# These are both False if ATTR_POWER is not set
power_on = kwargs.get(ATTR_POWER, False)
power_off = not kwargs.get(ATTR_POWER, True)

hsbk = find_hsbk(self.hass, **kwargs)

if not self.is_on:
if power_off:
await self.set_power(False)
# If fading on with color, set color immediately
if hsbk and power_on:
await self.set_color(hsbk, kwargs)
await self.set_power(True, duration=fade)
elif hsbk:
await self.set_color(hsbk, kwargs, duration=fade)
elif power_on:
await self.set_power(True, duration=fade)
else:
if power_on:
await self.set_power(True)
if hsbk:
await self.set_color(hsbk, kwargs, duration=fade)
if power_off:
await self.set_power(False, duration=fade)

# Avoid state ping-pong by holding off updates as the state settles
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)

# Update when the transition starts and ends
await self.update_during_transition(fade)
Expand Down
Loading