From 7b4b8e751689040d856a06f9a14ead5c21f0edbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Oct 2023 06:20:25 -1000 Subject: [PATCH] Refactor HomeKit to allow supported features/device class to change (#101719) --- homeassistant/components/homekit/__init__.py | 159 +++++--- .../components/homekit/accessories.py | 67 ++- .../components/homekit/type_cameras.py | 5 +- homeassistant/components/homekit/type_fans.py | 7 + .../components/homekit/type_humidifiers.py | 7 + .../components/homekit/type_lights.py | 8 +- .../components/homekit/type_remotes.py | 4 +- .../components/homekit/type_thermostats.py | 40 +- .../components/homekit/type_triggers.py | 6 +- tests/components/homekit/test_homekit.py | 144 ++++++- tests/components/homekit/test_type_fans.py | 35 +- .../homekit/test_type_humidifiers.py | 10 +- tests/components/homekit/test_type_lights.py | 82 +++- .../homekit/test_type_media_players.py | 71 ++-- tests/components/homekit/test_type_remote.py | 74 ++-- tests/components/homekit/test_type_sensors.py | 14 +- .../homekit/test_type_thermostats.py | 380 +++++++----------- .../components/homekit/test_type_triggers.py | 1 + 18 files changed, 662 insertions(+), 452 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index bb4efb7db6c..c3b7bf5d2e6 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -47,7 +47,14 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State, callback +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + HomeAssistant, + ServiceCall, + State, + callback, +) from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import ( config_validation as cv, @@ -55,6 +62,7 @@ from homeassistant.helpers import ( entity_registry as er, instance_id, ) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entityfilter import ( BASE_FILTER_SCHEMA, FILTER_SCHEMA, @@ -534,6 +542,7 @@ class HomeKit: self.driver: HomeDriver | None = None self.bridge: HomeBridge | None = None self._reset_lock = asyncio.Lock() + self._cancel_reload_dispatcher: CALLBACK_TYPE | None = None def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None: """Set up bridge and accessory driver.""" @@ -563,16 +572,28 @@ class HomeKit: async def async_reset_accessories(self, entity_ids: Iterable[str]) -> None: """Reset the accessory to load the latest configuration.""" + _LOGGER.debug("Resetting accessories: %s", entity_ids) async with self._reset_lock: if not self.bridge: - await self.async_reset_accessories_in_accessory_mode(entity_ids) + # For accessory mode reset and reload are the same + await self._async_reload_accessories_in_accessory_mode(entity_ids) return - await self.async_reset_accessories_in_bridge_mode(entity_ids) + await self._async_reset_accessories_in_bridge_mode(entity_ids) - async def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None: + async def async_reload_accessories(self, entity_ids: Iterable[str]) -> None: + """Reload the accessory to load the latest configuration.""" + _LOGGER.debug("Reloading accessories: %s", entity_ids) + async with self._reset_lock: + if not self.bridge: + await self._async_reload_accessories_in_accessory_mode(entity_ids) + return + await self._async_reload_accessories_in_bridge_mode(entity_ids) + + @callback + def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None: """Shutdown an accessory.""" assert self.driver is not None - await accessory.stop() + accessory.async_stop() # Deallocate the IIDs for the accessory iid_manager = accessory.iid_manager services: list[Service] = accessory.services @@ -582,7 +603,7 @@ class HomeKit: for char in characteristics: iid_manager.remove_obj(char) - async def async_reset_accessories_in_accessory_mode( + async def _async_reload_accessories_in_accessory_mode( self, entity_ids: Iterable[str] ) -> None: """Reset accessories in accessory mode.""" @@ -593,63 +614,88 @@ class HomeKit: return if not (state := self.hass.states.get(acc.entity_id)): _LOGGER.warning( - "The underlying entity %s disappeared during reset", acc.entity_id + "The underlying entity %s disappeared during reload", acc.entity_id ) return - await self._async_shutdown_accessory(acc) + self._async_shutdown_accessory(acc) if new_acc := self._async_create_single_accessory([state]): self.driver.accessory = new_acc - self.hass.async_create_task( - new_acc.run(), f"HomeKit Bridge Accessory: {new_acc.entity_id}" - ) - await self.async_config_changed() + # Run must be awaited here since it may change + # the accessories hash + await new_acc.run() + self._async_update_accessories_hash() - async def async_reset_accessories_in_bridge_mode( + def _async_remove_accessories_by_entity_id( self, entity_ids: Iterable[str] - ) -> None: - """Reset accessories in bridge mode.""" + ) -> list[str]: + """Remove accessories by entity id.""" assert self.aid_storage is not None assert self.bridge is not None - assert self.driver is not None - - new = [] + removed: list[str] = [] acc: HomeAccessory | None for entity_id in entity_ids: aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) if aid not in self.bridge.accessories: continue - _LOGGER.info( - "HomeKit Bridge %s will reset accessory with linked entity_id %s", - self._name, - entity_id, - ) - acc = await self.async_remove_bridge_accessory(aid) - if acc: - await self._async_shutdown_accessory(acc) - if acc and (state := self.hass.states.get(acc.entity_id)): - new.append(state) - else: - _LOGGER.warning( - "The underlying entity %s disappeared during reset", entity_id - ) + if acc := self.async_remove_bridge_accessory(aid): + self._async_shutdown_accessory(acc) + removed.append(entity_id) + return removed - if not new: - # No matched accessories, probably on another bridge + async def _async_reset_accessories_in_bridge_mode( + self, entity_ids: Iterable[str] + ) -> None: + """Reset accessories in bridge mode.""" + if not (removed := self._async_remove_accessories_by_entity_id(entity_ids)): + _LOGGER.debug("No accessories to reset in bridge mode for: %s", entity_ids) return - - await self.async_config_changed() - await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) - for state in new: - if acc := self.add_bridge_accessory(state): - self.hass.async_create_task( - acc.run(), f"HomeKit Bridge Accessory: {acc.entity_id}" - ) - await self.async_config_changed() - - async def async_config_changed(self) -> None: - """Call config changed which writes out the new config to disk.""" + # With a reset, we need to remove the accessories, + # and force config change so iCloud deletes them from + # the database. assert self.driver is not None - await self.hass.async_add_executor_job(self.driver.config_changed) + self._async_update_accessories_hash() + await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) + await self._async_recreate_removed_accessories_in_bridge_mode(removed) + + async def _async_reload_accessories_in_bridge_mode( + self, entity_ids: Iterable[str] + ) -> None: + """Reload accessories in bridge mode.""" + removed = self._async_remove_accessories_by_entity_id(entity_ids) + await self._async_recreate_removed_accessories_in_bridge_mode(removed) + + async def _async_recreate_removed_accessories_in_bridge_mode( + self, removed: list[str] + ) -> None: + """Recreate removed accessories in bridge mode.""" + for entity_id in removed: + if not (state := self.hass.states.get(entity_id)): + _LOGGER.warning( + "The underlying entity %s disappeared during reload", entity_id + ) + continue + if acc := self.add_bridge_accessory(state): + # Run must be awaited here since it may change + # the accessories hash + await acc.run() + self._async_update_accessories_hash() + + @callback + def _async_update_accessories_hash(self) -> bool: + """Update the accessories hash.""" + assert self.driver is not None + driver = self.driver + old_hash = driver.state.accessories_hash + new_hash = driver.accessories_hash + if driver.state.set_accessories_hash(new_hash): + _LOGGER.debug( + "Updating HomeKit accessories hash from %s -> %s", old_hash, new_hash + ) + driver.async_persist() + driver.async_update_advertisement() + return True + _LOGGER.debug("HomeKit accessories hash is unchanged: %s", new_hash) + return False def add_bridge_accessory(self, state: State) -> HomeAccessory | None: """Try adding accessory to bridge if configured beforehand.""" @@ -734,7 +780,8 @@ class HomeKit: ) ) - async def async_remove_bridge_accessory(self, aid: int) -> HomeAccessory | None: + @callback + def async_remove_bridge_accessory(self, aid: int) -> HomeAccessory | None: """Try adding accessory to bridge if configured beforehand.""" assert self.bridge is not None if acc := self.bridge.accessories.pop(aid, None): @@ -782,6 +829,11 @@ class HomeKit: if self.status != STATUS_READY: return self.status = STATUS_WAIT + self._cancel_reload_dispatcher = async_dispatcher_connect( + self.hass, + f"homekit_reload_entities_{self._entry_id}", + self.async_reload_accessories, + ) async_zc_instance = await zeroconf.async_get_async_instance(self.hass) uuid = await instance_id.async_get(self.hass) self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id) @@ -989,10 +1041,13 @@ class HomeKit: """Stop the accessory driver.""" if self.status != STATUS_RUNNING: return - self.status = STATUS_STOPPED - _LOGGER.debug("Driver stop for %s", self._name) - if self.driver: - await self.driver.async_stop() + async with self._reset_lock: + self.status = STATUS_STOPPED + assert self._cancel_reload_dispatcher is not None + self._cancel_reload_dispatcher() + _LOGGER.debug("Driver stop for %s", self._name) + if self.driver: + await self.driver.async_stop() @callback def _async_configure_linked_sensors( diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index d2b733cd88d..2e0d1e6c052 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -47,6 +47,7 @@ from homeassistant.core import ( callback as ha_callback, split_entity_id, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, @@ -69,7 +70,6 @@ from .const import ( CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, - DOMAIN, EVENT_HOMEKIT_CHANGED, HK_CHARGING, HK_NOT_CHARGABLE, @@ -81,7 +81,6 @@ from .const import ( MAX_VERSION_LENGTH, SERV_ACCESSORY_INFO, SERV_BATTERY_SERVICE, - SERVICE_HOMEKIT_RESET_ACCESSORY, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -111,6 +110,12 @@ SWITCH_TYPES = { } TYPES: Registry[str, type[HomeAccessory]] = Registry() +RELOAD_ON_CHANGE_ATTRS = ( + ATTR_SUPPORTED_FEATURES, + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, +) + def get_accessory( # noqa: C901 hass: HomeAssistant, driver: HomeDriver, state: State, aid: int | None, config: dict @@ -272,6 +277,8 @@ def get_accessory( # noqa: C901 class HomeAccessory(Accessory): # type: ignore[misc] """Adapter class for Accessory.""" + driver: HomeDriver + def __init__( self, hass: HomeAssistant, @@ -294,6 +301,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] *args, # noqa: B026 **kwargs, ) + self._reload_on_change_attrs = list(RELOAD_ON_CHANGE_ATTRS) self.config = config or {} if device_id: self.device_id: str | None = device_id @@ -464,7 +472,27 @@ class HomeAccessory(Accessory): # type: ignore[misc] self, event: EventType[EventStateChangedData] ) -> None: """Handle state change event listener callback.""" - self.async_update_state_callback(event.data["new_state"]) + new_state = event.data["new_state"] + old_state = event.data["old_state"] + if ( + new_state + and old_state + and STATE_UNAVAILABLE not in (old_state.state, new_state.state) + ): + old_attributes = old_state.attributes + new_attributes = new_state.attributes + for attr in self._reload_on_change_attrs: + if old_attributes.get(attr) != new_attributes.get(attr): + _LOGGER.debug( + "%s: Reloading HomeKit accessory since %s has changed from %s -> %s", + self.entity_id, + attr, + old_attributes.get(attr), + new_attributes.get(attr), + ) + self.async_reload() + return + self.async_update_state_callback(new_state) @ha_callback def async_update_state_callback(self, new_state: State | None) -> None: @@ -577,21 +605,30 @@ class HomeAccessory(Accessory): # type: ignore[misc] ) @ha_callback - def async_reset(self) -> None: - """Reset and recreate an accessory.""" - self.hass.async_create_task( - self.hass.services.async_call( - DOMAIN, - SERVICE_HOMEKIT_RESET_ACCESSORY, - {ATTR_ENTITY_ID: self.entity_id}, - ) + def async_reload(self) -> None: + """Reload and recreate an accessory and update the c# value in the mDNS record.""" + async_dispatcher_send( + self.hass, + f"homekit_reload_entities_{self.driver.entry_id}", + (self.entity_id,), ) - async def stop(self) -> None: + @ha_callback + def async_stop(self) -> None: """Cancel any subscriptions when the bridge is stopped.""" while self._subscriptions: self._subscriptions.pop(0)() + async def stop(self) -> None: + """Stop the accessory. + + This is overrides the parent class to call async_stop + since pyhap will call this function to stop the accessory + but we want to use our async_stop method since we need + it to be a callback to avoid races in reloading accessories. + """ + self.async_stop() + class HomeBridge(Bridge): # type: ignore[misc] """Adapter class for Bridge.""" @@ -637,7 +674,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] """Initialize a AccessoryDriver object.""" super().__init__(**kwargs) self.hass = hass - self._entry_id = entry_id + self.entry_id = entry_id self._bridge_name = bridge_name self._entry_title = entry_title self.iid_storage = iid_storage @@ -649,7 +686,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] """Override super function to dismiss setup message if paired.""" success = super().pair(client_username_bytes, client_public, client_permissions) if success: - async_dismiss_setup_message(self.hass, self._entry_id) + async_dismiss_setup_message(self.hass, self.entry_id) return cast(bool, success) @pyhap_callback # type: ignore[misc] @@ -662,7 +699,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] async_show_setup_message( self.hass, - self._entry_id, + self.entry_id, accessory_friendly_name(self._entry_title, self.accessory), self.state.pincode, self.accessory.xhm_uri(), diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 4c7ba5a7841..63b2bc023da 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -447,13 +447,14 @@ class Camera(HomeAccessory, PyhapCamera): self.sessions[session_id].pop(FFMPEG_WATCHER)() self.sessions[session_id].pop(FFMPEG_LOGGER).cancel() - async def stop(self): + @callback + def async_stop(self): """Stop any streams when the accessory is stopped.""" for session_info in self.sessions.values(): self.hass.async_create_background_task( self.stop_stream(session_info), "homekit.camera-stop-stream" ) - await super().stop() + super().async_stop() async def stop_stream(self, session_info): """Stop the stream for the given ``session_id``.""" diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index e3116c99e26..0ace0acd0b9 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -60,6 +60,13 @@ class Fan(HomeAccessory): self.chars = [] state = self.hass.states.get(self.entity_id) + self._reload_on_change_attrs.extend( + ( + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODES, + ) + ) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) percentage_step = state.attributes.get(ATTR_PERCENTAGE_STEP, 1) self.preset_modes = state.attributes.get(ATTR_PRESET_MODES) diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index f9f572a096c..de25717877c 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -76,6 +76,13 @@ class HumidifierDehumidifier(HomeAccessory): def __init__(self, *args): """Initialize a HumidifierDehumidifier accessory object.""" super().__init__(*args, category=CATEGORY_HUMIDIFIER) + self._reload_on_change_attrs.extend( + ( + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + ) + ) + self.chars = [] state = self.hass.states.get(self.entity_id) device_class = state.attributes.get( diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 83ce1c3f6cf..e8272358633 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -71,7 +71,13 @@ class Light(HomeAccessory): def __init__(self, *args): """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) - + self._reload_on_change_attrs.extend( + ( + ATTR_SUPPORTED_COLOR_MODES, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ) + ) self.chars = [] self._event_timer = None self._pending_events = {} diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index e440a5b3ac0..5dfc9777964 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -93,7 +93,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): state = self.hass.states.get(self.entity_id) assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - + self._reload_on_change_attrs.extend((source_list_key,)) self._mapped_sources_list: list[str] = [] self._mapped_sources: dict[str, str] = {} self.source_key = source_key @@ -204,8 +204,6 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): "%s: Sources out of sync. Rebuilding Accessory", self.entity_id, ) - # Sources are out of sync, recreate the accessory - self.async_reset() return _LOGGER.debug( diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index c34e9066160..85ad713012b 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -174,6 +174,15 @@ class Thermostat(HomeAccessory): self.hc_homekit_to_hass = None self.hc_hass_to_homekit = None hc_min_temp, hc_max_temp = self.get_temperature_range() + self._reload_on_change_attrs.extend( + ( + ATTR_MIN_HUMIDITY, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_FAN_MODES, + ATTR_HVAC_MODES, + ) + ) # Add additional characteristics if auto mode is supported self.chars = [] @@ -345,7 +354,7 @@ class Thermostat(HomeAccessory): ) self.char_target_fan_state.display_name = "Fan Auto" - self._async_update_state(state) + self.async_update_state(state) serv_thermostat.setter_callback = self._set_chars @@ -577,29 +586,6 @@ class Thermostat(HomeAccessory): @callback def async_update_state(self, new_state): - """Update thermostat state after state changed.""" - # We always recheck valid hvac modes as the entity - # may not have been fully setup when we saw it last - original_hc_hass_to_homekit = self.hc_hass_to_homekit - self._configure_hvac_modes(new_state) - - if self.hc_hass_to_homekit != original_hc_hass_to_homekit: - if self.char_target_heat_cool.value not in self.hc_homekit_to_hass: - # We must make sure the char value is - # in the new valid values before - # setting the new valid values or - # changing them with throw - self.char_target_heat_cool.set_value( - list(self.hc_homekit_to_hass)[0], should_notify=False - ) - self.char_target_heat_cool.override_properties( - valid_values=self.hc_hass_to_homekit - ) - - self._async_update_state(new_state) - - @callback - def _async_update_state(self, new_state): """Update state without rechecking the device features.""" attributes = new_state.attributes features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -727,6 +713,12 @@ class WaterHeater(HomeAccessory): def __init__(self, *args): """Initialize a WaterHeater accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._reload_on_change_attrs.extend( + ( + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ) + ) self._unit = self.hass.config.units.temperature_unit min_temp, max_temp = self.get_temperature_range() diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index ee737e01ff4..be8db07d517 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -6,7 +6,7 @@ from typing import Any from pyhap.const import CATEGORY_SENSOR -from homeassistant.core import CALLBACK_TYPE, Context +from homeassistant.core import CALLBACK_TYPE, Context, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.trigger import async_initialize_triggers @@ -112,10 +112,12 @@ class DeviceTriggerAccessory(HomeAccessory): _LOGGER.log, ) - async def stop(self) -> None: + @callback + def async_stop(self) -> None: """Handle accessory driver stop event.""" if self._remove_triggers: self._remove_triggers() + super().async_stop() @property def available(self) -> bool: diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 00281b491c4..ebb710561d9 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -36,7 +36,13 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id +from homeassistant.components.light import ( + ATTR_COLOR_MODE, + ATTR_SUPPORTED_COLOR_MODES, + ColorMode, +) from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_ZEROCONF from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -532,7 +538,7 @@ async def test_homekit_remove_accessory( acc_mock.stop = AsyncMock() homekit.bridge.accessories = {6: acc_mock} - acc = await homekit.async_remove_bridge_accessory(6) + acc = homekit.async_remove_bridge_accessory(6) assert acc is acc_mock assert len(homekit.bridge.accessories) == 0 @@ -876,6 +882,7 @@ async def test_homekit_stop(hass: HomeAssistant) -> None: # Test if driver is started homekit.status = STATUS_RUNNING + homekit._cancel_reload_dispatcher = lambda: None await homekit.async_stop() await hass.async_block_till_done() assert homekit.driver.async_stop.called is True @@ -919,6 +926,120 @@ async def test_homekit_reset_accessories( await homekit.async_stop() +async def test_homekit_reload_accessory_can_change_class( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in brdige mode. + + This test ensure when device class changes the HomeKit class changes. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "switch.outlet" + hass.states.async_set(entity_id, "on", {ATTR_DEVICE_CLASS: None}) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + bridge: HomeBridge = homekit.driver.accessory + await bridge.run() + switch_accessory = next(iter(bridge.accessories.values())) + assert type(switch_accessory).__name__ == "Switch" + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, "off", {ATTR_DEVICE_CLASS: SwitchDeviceClass.OUTLET} + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + outlet_accessory = next(iter(bridge.accessories.values())) + assert type(outlet_accessory).__name__ == "Outlet" + + await homekit.async_stop() + + +async def test_homekit_reload_accessory_in_accessory_mode( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in accessory mode. + + This test ensure a device class changes can change the class of + the accessory. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "switch.outlet" + hass.states.async_set(entity_id, "on", {ATTR_DEVICE_CLASS: None}) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + primary_accessory = homekit.driver.accessory + await primary_accessory.run() + assert type(primary_accessory).__name__ == "Switch" + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, "off", {ATTR_DEVICE_CLASS: SwitchDeviceClass.OUTLET} + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + primary_accessory = homekit.driver.accessory + assert type(primary_accessory).__name__ == "Outlet" + + await homekit.async_stop() + + +async def test_homekit_reload_accessory_same_class( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in bridge mode. + + The class of the accessory remains the same. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.color" + hass.states.async_set( + entity_id, + "on", + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_COLOR_MODE: ColorMode.HS}, + ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + bridge: HomeBridge = homekit.driver.accessory + await bridge.run() + light_accessory_color = next(iter(bridge.accessories.values())) + assert not hasattr(light_accessory_color, "char_color_temp") + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, + "on", + { + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS, ColorMode.COLOR_TEMP], + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + }, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + light_accessory_color_and_temp = next(iter(bridge.accessories.values())) + assert hasattr(light_accessory_color_and_temp, "char_color_temp") + + await homekit.async_stop() + + async def test_homekit_unpair( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: @@ -1076,8 +1197,8 @@ async def test_homekit_reset_accessories_not_supported( with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( "pyhap.accessory.Bridge.add_accessory" ) as mock_add_accessory, patch( - "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 @@ -1101,7 +1222,7 @@ async def test_homekit_reset_accessories_not_supported( ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 2 + assert hk_driver_async_update_advertisement.call_count == 1 assert not mock_add_accessory.called assert len(homekit.bridge.accessories) == 0 homekit.status = STATUS_STOPPED @@ -1165,22 +1286,25 @@ async def test_homekit_reset_accessories_not_bridged( with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( "pyhap.accessory.Bridge.add_accessory" ) as mock_add_accessory, patch( - "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) + assert hk_driver_async_update_advertisement.call_count == 0 acc_mock = MagicMock() acc_mock.entity_id = entity_id acc_mock.stop = AsyncMock() + acc_mock.to_HAP = lambda: {} aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING homekit.driver.aio_stop_event = MagicMock() + assert hk_driver_async_update_advertisement.call_count == 0 await hass.services.async_call( DOMAIN, @@ -1190,7 +1314,7 @@ async def test_homekit_reset_accessories_not_bridged( ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 0 + assert hk_driver_async_update_advertisement.call_count == 0 assert not mock_add_accessory.called homekit.status = STATUS_STOPPED @@ -1208,8 +1332,8 @@ async def test_homekit_reset_single_accessory( homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( - "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch( f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" @@ -1226,7 +1350,7 @@ async def test_homekit_reset_single_accessory( ) await hass.async_block_till_done() assert mock_run.called - assert hk_driver_config_changed.call_count == 1 + assert hk_driver_async_update_advertisement.call_count == 1 homekit.status = STATUS_READY await homekit.async_stop() diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index c39a3ea97c9..df54cce1b3f 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -129,7 +129,14 @@ async def test_fan_direction(hass: HomeAssistant, hk_driver, events) -> None: await hass.async_block_till_done() assert acc.char_direction.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_DIRECTION: DIRECTION_REVERSE}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.DIRECTION, + ATTR_DIRECTION: DIRECTION_REVERSE, + }, + ) await hass.async_block_till_done() assert acc.char_direction.value == 1 @@ -197,7 +204,11 @@ async def test_fan_oscillate(hass: HomeAssistant, hk_driver, events) -> None: await hass.async_block_till_done() assert acc.char_swing.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_OSCILLATING: True}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_FEATURES: FanEntityFeature.OSCILLATE, ATTR_OSCILLATING: True}, + ) await hass.async_block_till_done() assert acc.char_swing.value == 1 @@ -272,7 +283,15 @@ async def test_fan_speed(hass: HomeAssistant, hk_driver, events) -> None: await acc.run() await hass.async_block_till_done() - hass.states.async_set(entity_id, STATE_ON, {ATTR_PERCENTAGE: 100}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_PERCENTAGE_STEP: 25, + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 100, + }, + ) await hass.async_block_till_done() assert acc.char_speed.value == 100 @@ -306,7 +325,15 @@ async def test_fan_speed(hass: HomeAssistant, hk_driver, events) -> None: assert events[-1].data[ATTR_VALUE] == 42 # Verify speed is preserved from off to on - hass.states.async_set(entity_id, STATE_OFF, {ATTR_PERCENTAGE: 42}) + hass.states.async_set( + entity_id, + STATE_OFF, + { + ATTR_PERCENTAGE_STEP: 25, + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + }, + ) await hass.async_block_till_done() assert acc.char_speed.value == 50 assert acc.char_active.value == 0 diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index f3e4f96573d..e0b3e40967f 100644 --- a/tests/components/homekit/test_type_humidifiers.py +++ b/tests/components/homekit/test_type_humidifiers.py @@ -48,7 +48,9 @@ async def test_humidifier(hass: HomeAssistant, hk_driver, events) -> None: """Test if humidifier accessory and HA are updated accordingly.""" entity_id = "humidifier.test" - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: HumidifierDeviceClass.HUMIDIFIER} + ) await hass.async_block_till_done() acc = HumidifierDehumidifier( hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None @@ -77,7 +79,7 @@ async def test_humidifier(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, STATE_ON, - {ATTR_HUMIDITY: 47}, + {ATTR_HUMIDITY: 47, ATTR_DEVICE_CLASS: HumidifierDeviceClass.HUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 47.0 @@ -158,7 +160,7 @@ async def test_dehumidifier(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, STATE_ON, - {ATTR_HUMIDITY: 30}, + {ATTR_HUMIDITY: 30, ATTR_DEVICE_CLASS: HumidifierDeviceClass.DEHUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 30.0 @@ -169,7 +171,7 @@ async def test_dehumidifier(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, STATE_OFF, - {ATTR_HUMIDITY: 42}, + {ATTR_HUMIDITY: 42, ATTR_DEVICE_CLASS: HumidifierDeviceClass.DEHUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 42.0 diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 53d310d8e40..b023b7255a8 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -122,7 +122,8 @@ async def test_light_basic(hass: HomeAssistant, hk_driver, events) -> None: @pytest.mark.parametrize( - "supported_color_modes", [["brightness"], ["hs"], ["color_temp"]] + "supported_color_modes", + [[ColorMode.BRIGHTNESS], [ColorMode.HS], [ColorMode.COLOR_TEMP]], ) async def test_light_brightness( hass: HomeAssistant, hk_driver, events, supported_color_modes @@ -149,7 +150,11 @@ async def test_light_brightness( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 @@ -222,24 +227,48 @@ async def test_light_brightness( # 0 is a special case for homekit, see "Handle Brightness" # in update_state - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 255}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 255}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 # Ensure floats are handled - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 55.66}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 55.66}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 22 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 108.4}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 108.4}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 43 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0.0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0.0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 @@ -490,7 +519,9 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 -@pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) +@pytest.mark.parametrize( + "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] +) async def test_light_rgb_color( hass: HomeAssistant, hk_driver, events, supported_color_modes ) -> None: @@ -1221,7 +1252,7 @@ async def test_light_set_brightness_and_color( entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["hs"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_BRIGHTNESS: 255, }, ) @@ -1241,11 +1272,19 @@ async def test_light_set_brightness_and_color( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 - hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (4.5, 9.2)}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_HS_COLOR: (4.5, 9.2)}, + ) await hass.async_block_till_done() assert acc.char_hue.value == 4 assert acc.char_saturation.value == 9 @@ -1297,7 +1336,7 @@ async def test_light_min_max_mireds(hass: HomeAssistant, hk_driver, events) -> N entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 255, ATTR_MAX_MIREDS: 500.5, ATTR_MIN_MIREDS: 100.5, @@ -1319,7 +1358,7 @@ async def test_light_set_brightness_and_color_temp( entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 255, }, ) @@ -1338,11 +1377,22 @@ async def test_light_set_brightness_and_color_temp( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: (4461)}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], + ATTR_COLOR_TEMP_KELVIN: (4461), + }, + ) await hass.async_block_till_done() assert acc.char_color_temp.value == 224 diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 3842303ec84..104b9dd61ce 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -56,11 +56,12 @@ async def test_media_player_set_state(hass: HomeAssistant, hk_driver, events) -> } } entity_id = "media_player.test" + base_attrs = {ATTR_SUPPORTED_FEATURES: 20873, ATTR_MEDIA_VOLUME_MUTED: False} hass.states.async_set( entity_id, None, - {ATTR_SUPPORTED_FEATURES: 20873, ATTR_MEDIA_VOLUME_MUTED: False}, + base_attrs, ) await hass.async_block_till_done() acc = MediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, config) @@ -75,33 +76,35 @@ async def test_media_player_set_state(hass: HomeAssistant, hk_driver, events) -> assert acc.chars[FEATURE_PLAY_STOP].value is False assert acc.chars[FEATURE_TOGGLE_MUTE].value is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is True assert acc.chars[FEATURE_TOGGLE_MUTE].value is True - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is False - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is True - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is False - hass.states.async_set(entity_id, STATE_PLAYING) + hass.states.async_set(entity_id, STATE_PLAYING, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_PAUSE].value is True assert acc.chars[FEATURE_PLAY_STOP].value is True - hass.states.async_set(entity_id, STATE_PAUSED) + hass.states.async_set(entity_id, STATE_PAUSED, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_PAUSE].value is False - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set(entity_id, STATE_IDLE, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_STOP].value is False @@ -180,15 +183,16 @@ async def test_media_player_television( # Supports 'select_source', 'volume_step', 'turn_on', 'turn_off', # 'volume_mute', 'volume_set', 'pause' + base_attrs = { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], + } hass.states.async_set( entity_id, None, - { - ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - ATTR_SUPPORTED_FEATURES: 3469, - ATTR_MEDIA_VOLUME_MUTED: False, - ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], - }, + base_attrs, ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) @@ -203,32 +207,40 @@ async def test_media_player_television( assert acc.char_input_source.value == 0 assert acc.char_mute.value is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 assert acc.char_mute.value is True - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 2"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 2"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 1 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 3"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 3"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 2 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 5"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 5"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 0 assert caplog.records[-2].levelname == "DEBUG" @@ -358,12 +370,15 @@ async def test_media_player_television_basic( ) -> None: """Test if basic television accessory and HA are updated accordingly.""" entity_id = "media_player.television" - + base_attrs = { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 384, + } # Supports turn_on', 'turn_off' hass.states.async_set( entity_id, None, - {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 384}, + base_attrs, ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) @@ -374,15 +389,19 @@ async def test_media_player_television_basic( assert acc.chars_speaker == [] assert acc.support_select_source is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 3"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 3"} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 diff --git a/tests/components/homekit/test_type_remote.py b/tests/components/homekit/test_type_remote.py index 0c0a2266eb1..2e7a5174701 100644 --- a/tests/components/homekit/test_type_remote.py +++ b/tests/components/homekit/test_type_remote.py @@ -1,13 +1,14 @@ """Test different accessory types: Remotes.""" +from unittest.mock import patch + import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, - DOMAIN as HOMEKIT_DOMAIN, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, KEY_ARROW_RIGHT, - SERVICE_HOMEKIT_RESET_ACCESSORY, ) from homeassistant.components.homekit.type_remotes import ActivityRemote from homeassistant.components.remote import ( @@ -30,18 +31,19 @@ from tests.common import async_mock_service async def test_activity_remote( - hass: HomeAssistant, hk_driver, events, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, hk_driver: HomeDriver, events, caplog: pytest.LogCaptureFixture ) -> None: """Test if remote accessory and HA are updated accordingly.""" entity_id = "remote.harmony" + base_attrs = { + ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, + ATTR_CURRENT_ACTIVITY: "Apple TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], + } hass.states.async_set( entity_id, None, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + base_attrs, ) await hass.async_block_till_done() acc = ActivityRemote(hass, hk_driver, "ActivityRemote", entity_id, 2, None) @@ -58,47 +60,31 @@ async def test_activity_remote( hass.states.async_set( entity_id, STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + base_attrs, ) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + entity_id, STATE_ON, {**base_attrs, ATTR_CURRENT_ACTIVITY: "TV"} ) await hass.async_block_till_done() assert acc.char_input_source.value == 0 hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + entity_id, STATE_ON, {**base_attrs, ATTR_CURRENT_ACTIVITY: "Apple TV"} ) await hass.async_block_till_done() assert acc.char_input_source.value == 1 @@ -154,21 +140,19 @@ async def test_activity_remote( assert len(events) == 1 assert events[0].data[ATTR_KEY_NAME] == KEY_ARROW_RIGHT - call_reset_accessory = async_mock_service( - hass, HOMEKIT_DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY - ) - # A wild source appears - The accessory should rebuild itself - hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Amazon TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "Amazon TV"], - }, - ) - await hass.async_block_till_done() - assert call_reset_accessory[0].data[ATTR_ENTITY_ID] == entity_id + # A wild source appears - The accessory should reload itself + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + STATE_ON, + { + **base_attrs, + ATTR_CURRENT_ACTIVITY: "Amazon TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "Amazon TV"], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_activity_remote_bad_names( diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index c48ebb86ce3..d2f0d87c507 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -1,4 +1,6 @@ """Test different accessory types: Sensors.""" +from unittest.mock import patch + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homekit import get_accessory from homeassistant.components.homekit.const import ( @@ -71,11 +73,13 @@ async def test_temperature(hass: HomeAssistant, hk_driver) -> None: await hass.async_block_till_done() assert acc.char_temp.value == 0 - hass.states.async_set( - entity_id, "75.2", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT} - ) - await hass.async_block_till_done() - assert acc.char_temp.value == 24 + # The UOM changes, the accessory should reload itself + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, "75.2", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT} + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_humidity(hass: HomeAssistant, hk_driver) -> None: diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index da51efb43f2..a4ce765d795 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -79,21 +79,22 @@ from tests.common import async_mock_service async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -124,17 +125,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT, { + **base_attrs, ATTR_TEMPERATURE: 22.2, ATTR_CURRENT_TEMPERATURE: 17.8, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -148,17 +142,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 23.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -172,17 +159,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.FAN_ONLY, { + **base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 25.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -196,6 +176,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 19.0, ATTR_HVAC_ACTION: HVACAction.IDLE, @@ -211,7 +192,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, HVACMode.OFF, - {ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0}, + {**base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0}, ) await hass.async_block_till_done() assert acc.char_target_temp.value == 22.0 @@ -224,17 +205,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -248,17 +222,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 25.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -272,17 +239,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -296,17 +256,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.FAN_ONLY, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.FAN, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -320,7 +273,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.DRY, { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.DRYING, @@ -419,23 +372,23 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -458,18 +411,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 22.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -484,18 +430,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 24.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -510,18 +449,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -575,23 +507,23 @@ async def test_thermostat_mode_and_temp_change( ) -> None: """Test if accessory where the mode and temp change in the same call.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -614,18 +546,11 @@ async def test_thermostat_mode_and_temp_change( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -688,9 +613,9 @@ async def test_thermostat_mode_and_temp_change( async def test_thermostat_humidity(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly with humidity.""" entity_id = "climate.test" - + base_attrs = {ATTR_SUPPORTED_FEATURES: 4} # support_auto = True - hass.states.async_set(entity_id, HVACMode.OFF, {ATTR_SUPPORTED_FEATURES: 4}) + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) @@ -704,14 +629,18 @@ async def test_thermostat_humidity(hass: HomeAssistant, hk_driver, events) -> No assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == DEFAULT_MIN_HUMIDITY hass.states.async_set( - entity_id, HVACMode.HEAT_COOL, {ATTR_HUMIDITY: 65, ATTR_CURRENT_HUMIDITY: 40} + entity_id, + HVACMode.HEAT_COOL, + {**base_attrs, ATTR_HUMIDITY: 65, ATTR_CURRENT_HUMIDITY: 40}, ) await hass.async_block_till_done() assert acc.char_current_humidity.value == 40 assert acc.char_target_humidity.value == 65 hass.states.async_set( - entity_id, HVACMode.COOL, {ATTR_HUMIDITY: 35, ATTR_CURRENT_HUMIDITY: 70} + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_HUMIDITY: 35, ATTR_CURRENT_HUMIDITY: 70}, ) await hass.async_block_till_done() assert acc.char_current_humidity.value == 70 @@ -772,24 +701,24 @@ async def test_thermostat_humidity_with_target_humidity( async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: 4096, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.AUTO, + HVACMode.HEAT, + HVACMode.OFF, + ], + } # SUPPORT_ON_OFF = True hass.states.async_set( entity_id, HVACMode.HEAT, - { - ATTR_SUPPORTED_FEATURES: 4096, - ATTR_TEMPERATURE: 23.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -805,16 +734,10 @@ async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> entity_id, HVACMode.OFF, { + **base_attrs, ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], }, ) await hass.async_block_till_done() @@ -825,16 +748,10 @@ async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> entity_id, HVACMode.OFF, { + **base_attrs, ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], }, ) await hass.async_block_till_done() @@ -1566,12 +1483,15 @@ async def test_thermostat_without_target_temp_only_range( ) -> None: """Test a thermostat that only supports a range.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE}, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -1594,19 +1514,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 22.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1621,19 +1533,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 24.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1648,19 +1552,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1925,16 +1821,17 @@ async def test_thermostat_with_no_modes_when_we_first_see( ) -> None: """Test if a thermostat that is not ready when we first see it.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -1955,24 +1852,22 @@ async def test_thermostat_with_no_modes_when_we_first_see( assert acc.char_target_heat_cool.value == 0 - hass.states.async_set( - entity_id, - HVACMode.HEAT_COOL, - { - ATTR_TARGET_TEMP_HIGH: 22.0, - ATTR_TARGET_TEMP_LOW: 20.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.AUTO], - }, - ) - await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 20.0 - assert acc.char_cooling_thresh_temp.value == 22.0 - assert acc.char_current_heat_cool.value == 1 - assert acc.char_target_heat_cool.value == 3 - assert acc.char_current_temp.value == 18.0 - assert acc.char_display_units.value == 0 + # Verify reload on modes changed out from under us + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.AUTO], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_thermostat_with_no_off_after_recheck( @@ -1981,15 +1876,16 @@ async def test_thermostat_with_no_off_after_recheck( """Test if a thermostat that is not ready when we first see it that actually does not have off.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.COOL, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -2010,24 +1906,22 @@ async def test_thermostat_with_no_off_after_recheck( assert acc.char_target_heat_cool.value == 2 - hass.states.async_set( - entity_id, - HVACMode.HEAT_COOL, - { - ATTR_TARGET_TEMP_HIGH: 22.0, - ATTR_TARGET_TEMP_LOW: 20.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - }, - ) - await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 20.0 - assert acc.char_cooling_thresh_temp.value == 22.0 - assert acc.char_current_heat_cool.value == 1 - assert acc.char_target_heat_cool.value == 3 - assert acc.char_current_temp.value == 18.0 - assert acc.char_display_units.value == 0 + # Verify reload when modes change out from under us + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_thermostat_with_temp_clamps( @@ -2035,17 +1929,17 @@ async def test_thermostat_with_temp_clamps( ) -> None: """Test that tempatures are clamped to valid values to prevent homekit crash.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], + ATTR_MAX_TEMP: 50, + ATTR_MIN_TEMP: 100, + } hass.states.async_set( entity_id, HVACMode.COOL, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -2064,17 +1958,17 @@ async def test_thermostat_with_temp_clamps( assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 - assert acc.char_target_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 3 hass.states.async_set( entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 822.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 9918.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], }, ) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index 0374f3f1e94..84631646a6c 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -71,3 +71,4 @@ async def test_programmable_switch_button_fires_on_trigger( char = acc.get_characteristic(call.args[0]["aid"], call.args[0]["iid"]) assert char.display_name == CHAR_PROGRAMMABLE_SWITCH_EVENT await acc.stop() + await hass.async_block_till_done()