Bump pyIntesishome to 2.5.0 (#179979)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
James Nimmo
2026-08-24 14:51:52 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent b7022436a6
commit 6ab47ecb74
5 changed files with 145 additions and 66 deletions
+16 -41
View File
@@ -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
@@ -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"]
}
+1 -1
View File
@@ -3373,7 +3373,7 @@
"name": "IntesisHome",
"integration_type": "hub",
"config_flow": false,
"iot_class": "cloud_push"
"iot_class": "cloud_polling"
},
"iometer": {
"name": "IOmeter",
+1 -1
View File
@@ -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
+125 -21
View File
@@ -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