diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index dd71e3e3aba3..eca6bf75a0bd 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -1,7 +1,6 @@ """Support for IntesisHome and airconwithme Smart AC Controllers.""" import logging -from random import randrange from typing import Any, NamedTuple, override from pyintesishome import IHAuthenticationError, IHConnectionError, IntesisHome @@ -26,14 +25,14 @@ from homeassistant.const import ( CONF_DEVICE, CONF_PASSWORD, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -115,7 +114,7 @@ async def async_setup_platform( device_type=device_type, ) try: - await controller.poll_status() + await controller.connect() except IHAuthenticationError: _LOGGER.error("Invalid username or password") return @@ -124,6 +123,12 @@ async def async_setup_platform( raise PlatformNotReady from ex if ih_devices := controller.get_devices(): + # The controller is shared by every entity, so it outlives any one of + # them and is only torn down with Home Assistant itself. + async def _async_stop_controller(event: Event) -> None: + await controller.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_controller) async_add_entities( [ IntesisAC(ih_device_id, device, controller) @@ -144,7 +149,6 @@ class IntesisAC(ClimateEntity): """Represents an Intesishome air conditioning device.""" _attr_preset_modes = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST] - _attr_should_poll = False _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -155,7 +159,6 @@ class IntesisAC(ClimateEntity): self._ih_device = ih_device self._attr_name = ih_device.get("name") self._device_type = controller.device_type - self._connected = None self._attr_hvac_modes = [] self._outdoor_temp = None self._hvac_mode = None @@ -210,11 +213,11 @@ class IntesisAC(ClimateEntity): """Subscribe to event updates.""" _LOGGER.debug("Added climate device with state: %s", repr(self._ih_device)) self._controller.add_update_callback(self.async_update_callback) - try: - await self._controller.connect() - except IHConnectionError as ex: - _LOGGER.error("Exception connecting to IntesisHome: %s", ex) - raise PlatformNotReady from ex + + @override + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from event updates.""" + self._controller.remove_update_callback(self.async_update_callback) @property @override @@ -313,7 +316,6 @@ class IntesisAC(ClimateEntity): async def async_update(self) -> None: """Copy values from controller dictionary to climate device.""" # Update values from controller's device dictionary - self._connected = self._controller.is_connected self._attr_current_temperature = self._controller.get_temperature( self._device_id ) @@ -347,11 +349,6 @@ class IntesisAC(ClimateEntity): self._device_id ) - @override - async def async_will_remove_from_hass(self) -> None: - """Shutdown the controller when the device is being removed.""" - await self._controller.stop() - @property @override def icon(self) -> str | None: @@ -362,28 +359,6 @@ class IntesisAC(ClimateEntity): async def async_update_callback(self, device_id=None): """Let HA know there has been an update from the controller.""" - # Track changes in connection state - if not self._controller.is_connected and self._connected: - # Connection has dropped - self._connected = False - reconnect_minutes = 1 + randrange(10) - _LOGGER.error( - "Connection to %s API was lost. Reconnecting in %i minutes", - self._device_type, - reconnect_minutes, - ) - # Schedule reconnection - - async def try_connect(_now): - await self._controller.connect() - - async_call_later(self.hass, reconnect_minutes * 60, try_connect) - - if self._controller.is_connected and not self._connected: - # Connection has been restored - self._connected = True - _LOGGER.debug("Connection to %s API was restored", self._device_type) - if not device_id or self._device_id == device_id: # Update all devices if no device_id was specified _LOGGER.debug( @@ -410,8 +385,8 @@ class IntesisAC(ClimateEntity): @property @override def available(self) -> bool: - """If the device hasn't been able to connect, mark as unavailable.""" - return self._connected or self._connected is None + """Return whether the controller still has a path to the device.""" + return self._controller.is_available @property @override diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json index 31d6d0c5b400..cb1690723db8 100644 --- a/homeassistant/components/intesishome/manifest.json +++ b/homeassistant/components/intesishome/manifest.json @@ -3,8 +3,8 @@ "name": "IntesisHome", "codeowners": ["@jnimmo"], "documentation": "https://www.home-assistant.io/integrations/intesishome", - "iot_class": "cloud_push", + "iot_class": "cloud_polling", "loggers": ["pyintesishome"], "quality_scale": "legacy", - "requirements": ["pyintesishome==2.0.3"] + "requirements": ["pyintesishome==2.5.0"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7e51e8bdcded..d71d1b51b74f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3373,7 +3373,7 @@ "name": "IntesisHome", "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push" + "iot_class": "cloud_polling" }, "iometer": { "name": "IOmeter", diff --git a/requirements_all.txt b/requirements_all.txt index 255a537a8724..91fcfbad9d6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2276,7 +2276,7 @@ pyinsteon==1.6.4 pyintelliclima==0.4.1 # homeassistant.components.intesishome -pyintesishome==2.0.3 +pyintesishome==2.5.0 # homeassistant.components.ipma pyipma==3.0.10 diff --git a/tests/components/intesishome/test_climate.py b/tests/components/intesishome/test_climate.py index db68288ed016..252b1faa9ea0 100644 --- a/tests/components/intesishome/test_climate.py +++ b/tests/components/intesishome/test_climate.py @@ -1,15 +1,33 @@ """Tests for the IntesisHome climate platform.""" +from collections.abc import Generator +from datetime import timedelta from unittest.mock import MagicMock, patch -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME +from freezegun.api import FrozenDateTimeFactory +from pyintesishome import IHConnectionError +import pytest + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, SCAN_INTERVAL +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PLATFORM, + CONF_USERNAME, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import PLATFORM_NOT_READY_BASE_WAIT_TIME from homeassistant.setup import async_setup_component +from tests.common import async_fire_time_changed -async def test_setup_platform_registers_callback(hass: HomeAssistant) -> None: - """Test registering the synchronous library update callback during setup.""" +ENTITY_ID = "climate.office" + + +@pytest.fixture +def mock_controller() -> Generator[MagicMock]: + """Mock the pyintesishome controller.""" with patch( "homeassistant.components.intesishome.climate.IntesisHome", autospec=True ) as intesis_home: @@ -22,7 +40,8 @@ async def test_setup_platform_registers_callback(hass: HomeAssistant) -> None: controller.get_fan_speed_list.return_value = [] controller.get_mode_list.return_value = [] controller.add_update_callback = MagicMock() - controller.is_connected = True + controller.remove_update_callback = MagicMock() + controller.is_available = True controller.get_temperature.return_value = 22 controller.get_fan_speed.return_value = None controller.is_on.return_value = False @@ -38,21 +57,106 @@ async def test_setup_platform_registers_callback(hass: HomeAssistant) -> None: controller.get_horizontal_swing.return_value = "auto/stop" controller.get_heat_power_consumption.return_value = None controller.get_cool_power_consumption.return_value = None + yield controller - assert await async_setup_component( - hass, - CLIMATE_DOMAIN, - { - CLIMATE_DOMAIN: { - CONF_PLATFORM: "intesishome", - CONF_USERNAME: "user", - CONF_PASSWORD: "password", - } - }, - ) - await hass.async_block_till_done() - assert hass.states.get("climate.office") is not None - controller.add_update_callback.assert_called_once() - assert callable(controller.add_update_callback.call_args.args[0]) - controller.connect.assert_awaited_once_with() +async def setup_platform(hass: HomeAssistant) -> None: + """Set up the IntesisHome climate platform.""" + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + { + CLIMATE_DOMAIN: { + CONF_PLATFORM: "intesishome", + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + } + }, + ) + await hass.async_block_till_done() + + +async def test_setup_platform_registers_callback( + hass: HomeAssistant, mock_controller: MagicMock +) -> None: + """Test registering the synchronous library update callback during setup.""" + await setup_platform(hass) + + assert hass.states.get(ENTITY_ID) is not None + mock_controller.add_update_callback.assert_called_once() + assert callable(mock_controller.add_update_callback.call_args.args[0]) + mock_controller.connect.assert_awaited_once_with() + + +async def test_availability_follows_controller( + hass: HomeAssistant, mock_controller: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test polling picks up availability changes with no library callback.""" + await setup_platform(hass) + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + # Availability follows how long since the controller's poller last got + # through, so nothing calls back to announce either transition. + mock_controller.is_available = False + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + mock_controller.is_available = True + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + # Recovered without reconnecting: connect() was only called during setup. + assert mock_controller.connect.await_count == 1 + + +async def test_setup_platform_retries_on_connection_error( + hass: HomeAssistant, mock_controller: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test an unreachable API leaves the platform to be set up again later.""" + mock_controller.connect.side_effect = IHConnectionError + + await setup_platform(hass) + assert hass.states.get(ENTITY_ID) is None + + mock_controller.connect.side_effect = None + freezer.tick(timedelta(seconds=PLATFORM_NOT_READY_BASE_WAIT_TIME)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) is not None + + +async def test_removing_one_entity_keeps_controller_running( + hass: HomeAssistant, + mock_controller: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test the shared controller outlives the removal of a single entity.""" + mock_controller.get_devices.return_value = { + "device-id": {"name": "Office"}, + "other-device-id": {"name": "Lounge"}, + } + await setup_platform(hass) + + registered_callbacks = [ + call.args[0] for call in mock_controller.add_update_callback.call_args_list + ] + assert len(registered_callbacks) == 2 + + entity_registry.async_remove(ENTITY_ID) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) is None + mock_controller.stop.assert_not_awaited() + assert hass.states.get("climate.lounge").state != STATE_UNAVAILABLE + + # Only the removed entity detaches; the other keeps receiving updates. + removed_callbacks = [ + call.args[0] for call in mock_controller.remove_update_callback.call_args_list + ] + assert len(removed_callbacks) == 1 + assert removed_callbacks[0] in registered_callbacks