From 573c40cb1121420d7ba6e41238a0575a70a6d48c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Mar 2021 08:44:28 -1000 Subject: [PATCH] Ensure bond devices recover when wifi disconnects and reconnects (#47591) --- homeassistant/components/bond/entity.py | 2 +- tests/components/bond/test_entity.py | 169 ++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/components/bond/test_entity.py diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 6c56b47a9dc..cd120d79d56 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -102,7 +102,7 @@ class BondEntity(Entity): async def _async_update_if_bpup_not_alive(self, *_: Any) -> None: """Fetch via the API if BPUP is not alive.""" - if self._bpup_subs.alive and self._initialized: + if self._bpup_subs.alive and self._initialized and self._available: return assert self._update_lock is not None diff --git a/tests/components/bond/test_entity.py b/tests/components/bond/test_entity.py new file mode 100644 index 00000000000..e0a3f156ff5 --- /dev/null +++ b/tests/components/bond/test_entity.py @@ -0,0 +1,169 @@ +"""Tests for the Bond entities.""" +import asyncio +from datetime import timedelta +from unittest.mock import patch + +from bond_api import BPUPSubscriptions, DeviceType + +from homeassistant import core +from homeassistant.components import fan +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.util import utcnow + +from .common import patch_bond_device_state, setup_platform + +from tests.common import async_fire_time_changed + + +def ceiling_fan(name: str): + """Create a ceiling fan with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": ["SetSpeed", "SetDirection"], + } + + +async def test_bpup_goes_offline_and_recovers_same_entity(hass: core.HomeAssistant): + """Test that push updates fail and we fallback to polling and then bpup recovers. + + The BPUP recovery is triggered by an update for the entity and + we do not fallback to polling because state is in sync. + """ + bpup_subs = BPUPSubscriptions() + with patch( + "homeassistant.components.bond.BPUPSubscriptions", + return_value=bpup_subs, + ): + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 3, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 1, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33 + + bpup_subs.last_message_time = 0 + with patch_bond_device_state(side_effect=asyncio.TimeoutError): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) + await hass.async_block_till_done() + + assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE + + # Ensure we do not poll to get the state + # since bpup has recovered and we know we + # are back in sync + with patch_bond_device_state(side_effect=Exception): + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 2, "direction": 0}, + } + ) + await hass.async_block_till_done() + + state = hass.states.get("fan.name_1") + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + + +async def test_bpup_goes_offline_and_recovers_different_entity( + hass: core.HomeAssistant, +): + """Test that push updates fail and we fallback to polling and then bpup recovers. + + The BPUP recovery is triggered by an update for a different entity which + forces a poll since we need to re-get the state. + """ + bpup_subs = BPUPSubscriptions() + with patch( + "homeassistant.components.bond.BPUPSubscriptions", + return_value=bpup_subs, + ): + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 3, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 1, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33 + + bpup_subs.last_message_time = 0 + with patch_bond_device_state(side_effect=asyncio.TimeoutError): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) + await hass.async_block_till_done() + + assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE + + bpup_subs.notify( + { + "s": 200, + "t": "bond/not-this-device-id/update", + "b": {"power": 1, "speed": 2, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE + + with patch_bond_device_state(return_value={"power": 1, "speed": 1}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=430)) + await hass.async_block_till_done() + + state = hass.states.get("fan.name_1") + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + + +async def test_polling_fails_and_recovers(hass: core.HomeAssistant): + """Test that polling fails and we recover.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_device_state(side_effect=asyncio.TimeoutError): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) + await hass.async_block_till_done() + + assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE + + with patch_bond_device_state(return_value={"power": 1, "speed": 1}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) + await hass.async_block_till_done() + + state = hass.states.get("fan.name_1") + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 33