From c3d1db5db6fabe89136f0d7c1582b6f76e0e2f75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Oct 2023 20:49:35 -1000 Subject: [PATCH] Handle removal of accessories/services/chars in homekit_controller (#102179) --- .../homekit_controller/connection.py | 73 +- .../components/homekit_controller/const.py | 2 + .../homekit_controller/device_trigger.py | 7 +- .../components/homekit_controller/entity.py | 141 ++- .../components/homekit_controller/event.py | 16 +- tests/components/homekit_controller/common.py | 2 + .../fixtures/ecobee3_service_removed.json | 561 ++++++++++++ ...home_assistant_bridge_fan_one_removed.json | 166 ++++ .../snapshots/test_init.ambr | 829 ++++++++++++++++++ .../specific_devices/test_ecobee3.py | 88 ++ .../test_fan_that_changes_features.py | 87 ++ 11 files changed, 1907 insertions(+), 65 deletions(-) create mode 100644 tests/components/homekit_controller/fixtures/ecobee3_service_removed.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 141800a0b62..3aac6484bed 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -29,7 +29,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later, async_track_time_interval from .config_flow import normalize_hkid from .const import ( @@ -43,6 +43,7 @@ from .const import ( IDENTIFIER_LEGACY_SERIAL_NUMBER, IDENTIFIER_SERIAL_NUMBER, STARTUP_EXCEPTIONS, + SUBSCRIBE_COOLDOWN, ) from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry @@ -116,7 +117,7 @@ class HKDevice: # This just tracks aid/iid pairs so we know if a HK service has been # mapped to a HA entity. - self.entities: list[tuple[int, int | None, int | None]] = [] + self.entities: set[tuple[int, int | None, int | None]] = set() # A map of aid -> device_id # Useful when routing events to triggers @@ -124,7 +125,7 @@ class HKDevice: self.available = False - self.pollable_characteristics: list[tuple[int, int]] = [] + self.pollable_characteristics: set[tuple[int, int]] = set() # Never allow concurrent polling of the same accessory or bridge self._polling_lock = asyncio.Lock() @@ -134,7 +135,7 @@ class HKDevice: # This is set to True if we can't rely on serial numbers to be unique self.unreliable_serial_numbers = False - self.watchable_characteristics: list[tuple[int, int]] = [] + self.watchable_characteristics: set[tuple[int, int]] = set() self._debounced_update = Debouncer( hass, @@ -147,6 +148,8 @@ class HKDevice: self._availability_callbacks: set[CALLBACK_TYPE] = set() self._config_changed_callbacks: set[CALLBACK_TYPE] = set() self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {} + self._pending_subscribes: set[tuple[int, int]] = set() + self._subscribe_timer: CALLBACK_TYPE | None = None @property def entity_map(self) -> Accessories: @@ -162,26 +165,51 @@ class HKDevice: self, characteristics: list[tuple[int, int]] ) -> None: """Add (aid, iid) pairs that we need to poll.""" - self.pollable_characteristics.extend(characteristics) + self.pollable_characteristics.update(characteristics) - def remove_pollable_characteristics(self, accessory_id: int) -> None: + def remove_pollable_characteristics( + self, characteristics: list[tuple[int, int]] + ) -> None: """Remove all pollable characteristics by accessory id.""" - self.pollable_characteristics = [ - char for char in self.pollable_characteristics if char[0] != accessory_id - ] + for aid_iid in characteristics: + self.pollable_characteristics.discard(aid_iid) - async def add_watchable_characteristics( + def add_watchable_characteristics( self, characteristics: list[tuple[int, int]] ) -> None: """Add (aid, iid) pairs that we need to poll.""" - self.watchable_characteristics.extend(characteristics) - await self.pairing.subscribe(characteristics) + self.watchable_characteristics.update(characteristics) + self._pending_subscribes.update(characteristics) + # Try to subscribe to the characteristics all at once + if not self._subscribe_timer: + self._subscribe_timer = async_call_later( + self.hass, + SUBSCRIBE_COOLDOWN, + self._async_subscribe, + ) - def remove_watchable_characteristics(self, accessory_id: int) -> None: + @callback + def _async_cancel_subscription_timer(self) -> None: + """Cancel the subscribe timer.""" + if self._subscribe_timer: + self._subscribe_timer() + self._subscribe_timer = None + + async def _async_subscribe(self, _now: datetime) -> None: + """Subscribe to characteristics.""" + self._subscribe_timer = None + if self._pending_subscribes: + subscribes = self._pending_subscribes.copy() + self._pending_subscribes.clear() + await self.pairing.subscribe(subscribes) + + def remove_watchable_characteristics( + self, characteristics: list[tuple[int, int]] + ) -> None: """Remove all pollable characteristics by accessory id.""" - self.watchable_characteristics = [ - char for char in self.watchable_characteristics if char[0] != accessory_id - ] + for aid_iid in characteristics: + self.watchable_characteristics.discard(aid_iid) + self._pending_subscribes.discard(aid_iid) @callback def async_set_available_state(self, available: bool) -> None: @@ -264,6 +292,7 @@ class HKDevice: entry.async_on_unload( pairing.dispatcher_availability_changed(self.async_set_available_state) ) + entry.async_on_unload(self._async_cancel_subscription_timer) await self.async_process_entity_map() @@ -605,8 +634,6 @@ class HKDevice: async def async_update_new_accessories_state(self) -> None: """Process a change in the pairings accessories state.""" await self.async_process_entity_map() - if self.watchable_characteristics: - await self.pairing.subscribe(self.watchable_characteristics) for callback_ in self._config_changed_callbacks: callback_() await self.async_update() @@ -623,7 +650,7 @@ class HKDevice: if (accessory.aid, None, None) in self.entities: continue if handler(accessory): - self.entities.append((accessory.aid, None, None)) + self.entities.add((accessory.aid, None, None)) break def add_char_factory(self, add_entities_cb: AddCharacteristicCb) -> None: @@ -639,7 +666,7 @@ class HKDevice: if (accessory.aid, service.iid, char.iid) in self.entities: continue if handler(char): - self.entities.append((accessory.aid, service.iid, char.iid)) + self.entities.add((accessory.aid, service.iid, char.iid)) break def add_listener(self, add_entities_cb: AddServiceCb) -> None: @@ -687,7 +714,7 @@ class HKDevice: for listener in callbacks: if listener(service): - self.entities.append((aid, None, iid)) + self.entities.add((aid, None, iid)) break async def async_load_platform(self, platform: str) -> None: @@ -811,7 +838,7 @@ class HKDevice: @callback def _remove_characteristics_callback( - self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE + self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE ) -> None: """Remove a characteristics callback.""" for aid_iid in characteristics: @@ -821,7 +848,7 @@ class HKDevice: @callback def async_subscribe( - self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE + self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE ) -> CALLBACK_TYPE: """Add characteristics to the watch list.""" for aid_iid in characteristics: diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index f60dc669968..cc2c28cb5dc 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -120,3 +120,5 @@ STARTUP_EXCEPTIONS = ( # also happens to be the same value used by # the update coordinator. DEBOUNCE_COOLDOWN = 10 # seconds + +SUBSCRIBE_COOLDOWN = 0.25 # seconds diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 9eab0fbb098..fa4c1c171c2 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -64,7 +64,8 @@ class TriggerSource: self._callbacks: dict[tuple[str, str], list[Callable[[Any], None]]] = {} self._iid_trigger_keys: dict[int, set[tuple[str, str]]] = {} - async def async_setup( + @callback + def async_setup( self, connection: HKDevice, aid: int, triggers: list[dict[str, Any]] ) -> None: """Set up a set of triggers for a device. @@ -78,7 +79,7 @@ class TriggerSource: self._triggers[trigger_key] = trigger_data iid = trigger_data["characteristic"] self._iid_trigger_keys.setdefault(iid, set()).add(trigger_key) - await connection.add_watchable_characteristics([(aid, iid)]) + connection.add_watchable_characteristics([(aid, iid)]) def fire(self, iid: int, ev: dict[str, Any]) -> None: """Process events that have been received from a HomeKit accessory.""" @@ -237,7 +238,7 @@ async def async_setup_triggers_for_entry( return False trigger = async_get_or_create_trigger_source(conn.hass, device_id) - hass.async_create_task(trigger.async_setup(conn, aid, triggers)) + trigger.async_setup(conn, aid, triggers) return True diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 04dabf410a4..a965084bdae 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from aiohomekit.model import Service, Services from aiohomekit.model.characteristics import ( EVENT_CHARACTERISTICS, Characteristic, @@ -11,7 +12,7 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import ServicesTypes -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType @@ -20,10 +21,21 @@ from .connection import HKDevice, valid_serial_number from .utils import folded_name +def _get_service_by_iid_or_none(services: Services, iid: int) -> Service | None: + """Return a service by iid or None.""" + try: + return services.iid(iid) + except KeyError: + return None + + class HomeKitEntity(Entity): """Representation of a Home Assistant HomeKit device.""" _attr_should_poll = False + pollable_characteristics: list[tuple[int, int]] + watchable_characteristics: list[tuple[int, int]] + all_characteristics: set[tuple[int, int]] def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: """Initialise a generic HomeKit device.""" @@ -31,49 +43,77 @@ class HomeKitEntity(Entity): self._aid = devinfo["aid"] self._iid = devinfo["iid"] self._char_name: str | None = None - self.all_characteristics: set[tuple[int, int]] = set() - self._async_set_accessory_and_service() - self.setup() - + self._char_subscription: CALLBACK_TYPE | None = None + self.async_setup() super().__init__() @callback - def _async_set_accessory_and_service(self) -> None: - """Set the accessory and service for this entity.""" - accessory = self._accessory - self.accessory = accessory.entity_map.aid(self._aid) - self.service = self.accessory.services.iid(self._iid) - self.accessory_info = self.accessory.services.first( - service_type=ServicesTypes.ACCESSORY_INFORMATION - ) + def _async_handle_entity_removed(self) -> None: + """Handle entity removal.""" + # We call _async_unsubscribe_chars as soon as we + # know the entity is about to be removed so we do not try to + # update characteristics that no longer exist. It will get + # called in async_will_remove_from_hass as well, but that is + # too late. + self._async_unsubscribe_chars() + self.hass.async_create_task(self.async_remove(force_remove=True)) + + @callback + def _async_remove_entity_if_accessory_or_service_disappeared(self) -> bool: + """Handle accessory or service disappearance.""" + entity_map = self._accessory.entity_map + if not entity_map.has_aid(self._aid) or not _get_service_by_iid_or_none( + entity_map.aid(self._aid).services, self._iid + ): + self._async_handle_entity_removed() + return True + return False @callback def _async_config_changed(self) -> None: """Handle accessory discovery changes.""" - self._async_set_accessory_and_service() + if not self._async_remove_entity_if_accessory_or_service_disappeared(): + self._async_reconfigure() + + @callback + def _async_reconfigure(self) -> None: + """Reconfigure the entity.""" + self._async_unsubscribe_chars() + self.async_setup() + self._async_subscribe_chars() self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Entity added to hass.""" - accessory = self._accessory + self._async_subscribe_chars() self.async_on_remove( - accessory.async_subscribe( - self.all_characteristics, self._async_write_ha_state - ) + self._accessory.async_subscribe_config_changed(self._async_config_changed) ) self.async_on_remove( - accessory.async_subscribe_availability(self._async_write_ha_state) + self._accessory.async_subscribe_availability(self._async_write_ha_state) ) - self.async_on_remove( - accessory.async_subscribe_config_changed(self._async_config_changed) - ) - accessory.add_pollable_characteristics(self.pollable_characteristics) - await accessory.add_watchable_characteristics(self.watchable_characteristics) async def async_will_remove_from_hass(self) -> None: """Prepare to be removed from hass.""" - self._accessory.remove_pollable_characteristics(self._aid) - self._accessory.remove_watchable_characteristics(self._aid) + self._async_unsubscribe_chars() + + @callback + def _async_unsubscribe_chars(self): + """Handle unsubscribing from characteristics.""" + if self._char_subscription: + self._char_subscription() + self._char_subscription = None + self._accessory.remove_pollable_characteristics(self.pollable_characteristics) + self._accessory.remove_watchable_characteristics(self.watchable_characteristics) + + @callback + def _async_subscribe_chars(self): + """Handle registering characteristics to watch and subscribe.""" + self._accessory.add_pollable_characteristics(self.pollable_characteristics) + self._accessory.add_watchable_characteristics(self.watchable_characteristics) + self._char_subscription = self._accessory.async_subscribe( + self.all_characteristics, self._async_write_ha_state + ) async def async_put_characteristics(self, characteristics: dict[str, Any]) -> None: """Write characteristics to the device. @@ -92,10 +132,22 @@ class HomeKitEntity(Entity): payload = self.service.build_update(characteristics) return await self._accessory.put_characteristics(payload) - def setup(self) -> None: + @callback + def async_setup(self) -> None: """Configure an entity based on its HomeKit characteristics metadata.""" - self.pollable_characteristics: list[tuple[int, int]] = [] - self.watchable_characteristics: list[tuple[int, int]] = [] + accessory = self._accessory + self.accessory = accessory.entity_map.aid(self._aid) + self.service = self.accessory.services.iid(self._iid) + self.accessory_info = self.accessory.services.first( + service_type=ServicesTypes.ACCESSORY_INFORMATION + ) + # If we re-setup, we need to make sure we make new + # lists since we passed them to the connection before + # and we do not want to inadvertently modify the old + # ones. + self.pollable_characteristics = [] + self.watchable_characteristics = [] + self.all_characteristics = set() char_types = self.get_characteristic_types() @@ -203,7 +255,7 @@ class AccessoryEntity(HomeKitEntity): return f"{self._accessory.unique_id}_{self._aid}" -class CharacteristicEntity(HomeKitEntity): +class BaseCharacteristicEntity(HomeKitEntity): """A HomeKit entity that is related to an single characteristic rather than a whole service. This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with @@ -217,6 +269,35 @@ class CharacteristicEntity(HomeKitEntity): self._char = char super().__init__(accessory, devinfo) + @callback + def _async_remove_entity_if_characteristics_disappeared(self) -> bool: + """Handle characteristic disappearance.""" + if ( + not self._accessory.entity_map.aid(self._aid) + .services.iid(self._iid) + .get_char_by_iid(self._char.iid) + ): + self._async_handle_entity_removed() + return True + return False + + @callback + def _async_config_changed(self) -> None: + """Handle accessory discovery changes.""" + if ( + not self._async_remove_entity_if_accessory_or_service_disappeared() + and not self._async_remove_entity_if_characteristics_disappeared() + ): + super()._async_reconfigure() + + +class CharacteristicEntity(BaseCharacteristicEntity): + """A HomeKit entity that is related to an single characteristic rather than a whole service. + + This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with + the service entity. + """ + @property def old_unique_id(self) -> str: """Return the old ID of this device.""" diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py index 9d70127f74a..86046415e35 100644 --- a/homeassistant/components/homekit_controller/event.py +++ b/homeassistant/components/homekit_controller/event.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice -from .entity import HomeKitEntity +from .entity import BaseCharacteristicEntity INPUT_EVENT_VALUES = { InputEventValues.SINGLE_PRESS: "single_press", @@ -26,7 +26,7 @@ INPUT_EVENT_VALUES = { } -class HomeKitEventEntity(HomeKitEntity, EventEntity): +class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): """Representation of a Homekit event entity.""" _attr_should_poll = False @@ -44,10 +44,8 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): "aid": service.accessory.aid, "iid": service.iid, }, + service.characteristics_by_type[CharacteristicsTypes.INPUT_EVENT], ) - self._characteristic = service.characteristics_by_type[ - CharacteristicsTypes.INPUT_EVENT - ] self.entity_description = entity_description @@ -55,7 +53,7 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): # clamp InputEventValues for this exact device self._attr_event_types = [ INPUT_EVENT_VALUES[v] - for v in clamp_enum_to_char(InputEventValues, self._characteristic) + for v in clamp_enum_to_char(InputEventValues, self._char) ] def get_characteristic_types(self) -> list[str]: @@ -68,19 +66,19 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): self.async_on_remove( self._accessory.async_subscribe( - [(self._aid, self._characteristic.iid)], + {(self._aid, self._char.iid)}, self._handle_event, ) ) @callback def _handle_event(self): - if self._characteristic.value is None: + if self._char.value is None: # For IP backed devices the characteristic is marked as # pollable, but always returns None when polled # Make sure we don't explode if we see that edge case. return - self._trigger_event(INPUT_EVENT_VALUES[self._characteristic.value]) + self._trigger_event(INPUT_EVENT_VALUES[self._char.value]) self.async_write_ha_state() diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 4fbbfea932f..9642b18dd1c 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -26,6 +26,7 @@ from homeassistant.components.homekit_controller.const import ( DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, IDENTIFIER_ACCESSORY_ID, + SUBSCRIBE_COOLDOWN, ) from homeassistant.components.homekit_controller.utils import async_get_controller from homeassistant.config_entries import ConfigEntry @@ -238,6 +239,7 @@ async def setup_test_accessories_with_controller( config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) + await time_changed(hass, SUBSCRIBE_COOLDOWN) await hass.async_block_till_done() return config_entry, pairing diff --git a/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json b/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json new file mode 100644 index 00000000000..ba26866939c --- /dev/null +++ b/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json @@ -0,0 +1,561 @@ +[ + { + "aid": 1, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "HomeW", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 3 + }, + { + "value": "123456789012", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 4 + }, + { + "value": "ecobee3", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 5 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 6 + }, + { + "value": "4.2.394", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "value": 0, + "perms": ["pr", "ev"], + "type": "A6", + "format": "uint32", + "iid": 9 + } + ], + "iid": 1 + }, + { + "type": "A2", + "characteristics": [ + { + "value": "1.1.0", + "perms": ["pr"], + "maxLen": 64, + "type": "37", + "format": "string", + "iid": 31 + } + ], + "iid": 30 + }, + { + "primary": true, + "type": "4A", + "characteristics": [ + { + "value": 1, + "maxValue": 2, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "F", + "minValue": 0, + "format": "uint8", + "iid": 17 + }, + { + "value": 1, + "maxValue": 3, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "type": "33", + "minValue": 0, + "format": "uint8", + "iid": 18 + }, + { + "value": 21.8, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 19 + }, + { + "value": 22.2, + "maxValue": 33.3, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "35", + "minValue": 7.2, + "format": "float", + "iid": 20 + }, + { + "value": 1, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "type": "36", + "minValue": 0, + "format": "uint8", + "iid": 21 + }, + { + "value": 24.4, + "maxValue": 33.3, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "D", + "minValue": 18.3, + "format": "float", + "iid": 22 + }, + { + "value": 22.2, + "maxValue": 26.1, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "12", + "minValue": 7.2, + "format": "float", + "iid": 23 + }, + { + "value": 34, + "maxValue": 100, + "minStep": 1, + "perms": ["pr", "ev"], + "unit": "percentage", + "type": "10", + "minValue": 0, + "format": "float", + "iid": 24 + }, + { + "value": 36, + "maxValue": 50, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "unit": "percentage", + "type": "34", + "minValue": 20, + "format": "float", + "iid": 25 + }, + { + "value": "HomeW", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 27 + } + ], + "iid": 16 + } + ] + }, + { + "aid": 2, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2049 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 2050 + }, + { + "value": "AB1C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 2051 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 2052 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 2053 + } + ], + "iid": 1 + }, + { + "type": "8A", + "characteristics": [ + { + "value": 21.5, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 2064 + }, + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2067 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 2066 + }, + { + "value": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "minValue": 0, + "format": "uint8", + "iid": 2065 + } + ], + "iid": 55 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 2060 + }, + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2063 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 2062 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 2061 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 3620, + "format": "int", + "iid": 2059 + } + ], + "iid": 56 + } + ] + }, + { + "aid": 3, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3073 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 3074 + }, + { + "value": "AB2C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 3075 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 3076 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 3077 + } + ], + "iid": 1 + }, + { + "type": "8A", + "characteristics": [ + { + "value": 21, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 3088 + }, + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3091 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 3090 + }, + { + "value": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "minValue": 0, + "format": "uint8", + "iid": 3089 + } + ], + "iid": 55 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 3084 + }, + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3087 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 3086 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 3085 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 5766, + "format": "int", + "iid": 3083 + } + ], + "iid": 56 + } + ] + }, + { + "aid": 4, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Basement", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 4097 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 4098 + }, + { + "value": "AB3C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 4099 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 4100 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 4101 + } + ], + "iid": 1 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 4108 + }, + { + "value": "Basement", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 4111 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 4110 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 4109 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 5472, + "format": "int", + "iid": 4107 + } + ], + "iid": 56 + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json new file mode 100644 index 00000000000..f7aaab11384 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json @@ -0,0 +1,166 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Bridge" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Home Assistant Bridge" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "homekit.bridge" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + } + ] + }, + { + "aid": 1256851357, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Fan" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Living Room Fan" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "fan.living_room_fan" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "Active", + "format": "uint8", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "type": "000000B0-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationDirection", + "format": "int", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "type": "00000028-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "SwingMode", + "format": "uint8", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "type": "000000B6-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationSpeed", + "format": "float", + "iid": 12, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": ["pr", "pw", "ev"], + "type": "00000029-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100 + } + ], + "iid": 8, + "stype": "fanv2", + "type": "000000B7-0000-1000-8000-0026BB765291" + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index d02aaa1ae49..1517862664d 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -3935,6 +3935,656 @@ }), ]) # --- +# name: test_snapshots[ecobee3_service_removed] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Basement', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement', + }), + 'entity_id': 'binary_sensor.basement', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_4101', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Kitchen', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kitchen', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Kitchen', + }), + 'entity_id': 'binary_sensor.kitchen', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2053', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Identify', + }), + 'entity_id': 'button.kitchen_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.kitchen_temperature', + 'state': '21.5', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Porch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.porch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Porch', + }), + 'entity_id': 'binary_sensor.porch', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.porch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Porch Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_3077', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Porch Identify', + }), + 'entity_id': 'button.porch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.porch_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Porch Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.porch_temperature', + 'state': '21', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[ecobee_501] list([ dict({ @@ -6117,6 +6767,185 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_fan_one_removed] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'oscillating': False, + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[homespan_daikin_bridge] list([ dict({ diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 1cdd4ccb907..eefb3124d0a 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -252,3 +252,91 @@ async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None: occ3 = entity_registry.async_get("binary_sensor.basement") assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + +async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None: + """Test that sensors are automatically removed.""" + entity_registry = er.async_get(hass) + + # Set up a base Ecobee 3 with additional sensors. + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await setup_test_accessories(hass, accessories) + + climate = entity_registry.async_get("climate.homew") + assert climate.unique_id == "00:00:00:00:00:00_1_16" + + occ1 = entity_registry.async_get("binary_sensor.kitchen") + assert occ1.unique_id == "00:00:00:00:00:00_2_56" + + occ2 = entity_registry.async_get("binary_sensor.porch") + assert occ2.unique_id == "00:00:00:00:00:00_3_56" + + occ3 = entity_registry.async_get("binary_sensor.basement") + assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + assert hass.states.get("binary_sensor.kitchen") is not None + assert hass.states.get("binary_sensor.porch") is not None + assert hass.states.get("binary_sensor.basement") is not None + + # Now remove 3 new sensors at runtime - sensors should disappear and climate + # shouldn't be duplicated. + accessories = await setup_accessories_from_file(hass, "ecobee3_no_sensors.json") + await device_config_changed(hass, accessories) + + assert hass.states.get("binary_sensor.kitchen") is None + assert hass.states.get("binary_sensor.porch") is None + assert hass.states.get("binary_sensor.basement") is None + + # Now add the sensors back + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await device_config_changed(hass, accessories) + + occ1 = entity_registry.async_get("binary_sensor.kitchen") + assert occ1.unique_id == "00:00:00:00:00:00_2_56" + + occ2 = entity_registry.async_get("binary_sensor.porch") + assert occ2.unique_id == "00:00:00:00:00:00_3_56" + + occ3 = entity_registry.async_get("binary_sensor.basement") + assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + # Currently it is not possible to add the entities back once + # they are removed because _add_new_entities has a guard to prevent + # the same entity from being added twice. + + +async def test_ecobee3_services_and_chars_removed( + hass: HomeAssistant, +) -> None: + """Test handling removal of some services and chars.""" + entity_registry = er.async_get(hass) + + # Set up a base Ecobee 3 with additional sensors. + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await setup_test_accessories(hass, accessories) + + climate = entity_registry.async_get("climate.homew") + assert climate.unique_id == "00:00:00:00:00:00_1_16" + + assert hass.states.get("sensor.basement_temperature") is not None + assert hass.states.get("sensor.kitchen_temperature") is not None + assert hass.states.get("sensor.porch_temperature") is not None + + assert hass.states.get("select.homew_current_mode") is not None + assert hass.states.get("button.homew_clear_hold") is not None + + # Reconfigure with some of the chars removed and the basement temperature sensor + accessories = await setup_accessories_from_file( + hass, "ecobee3_service_removed.json" + ) + await device_config_changed(hass, accessories) + + # Make sure the climate entity is still there + assert hass.states.get("climate.homew") is not None + + # Make sure the basement temperature sensor is gone + assert hass.states.get("sensor.basement_temperature") is None + + # Make sure the current mode select and clear hold button are gone + assert hass.states.get("select.homew_current_mode") is None + assert hass.states.get("button.homew_clear_hold") is None diff --git a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py index a7750edf9aa..bae0c0e4ff1 100644 --- a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py +++ b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py @@ -53,3 +53,90 @@ async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: ) fan_state = hass.states.get("fan.ceiling_fan") assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + +async def test_fan_remove_feature_at_runtime(hass: HomeAssistant) -> None: + """Test that features can be removed at runtime.""" + entity_registry = er.async_get(hass) + + # Set up a basic fan that does not support oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan.json" + ) + await setup_test_accessories(hass, accessories) + + fan = entity_registry.async_get("fan.living_room_fan") + assert fan.unique_id == "00:00:00:00:00:00_1256851357_8" + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + + fan = entity_registry.async_get("fan.ceiling_fan") + assert fan.unique_id == "00:00:00:00:00:00_766313939_8" + + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + # Now change the config to add oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_fan.json" + ) + await device_config_changed(hass, accessories) + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + ) + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + +async def test_bridge_with_two_fans_one_removed(hass: HomeAssistant) -> None: + """Test a bridge with two fans and one gets removed.""" + entity_registry = er.async_get(hass) + + # Set up a basic fan that does not support oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan.json" + ) + await setup_test_accessories(hass, accessories) + + fan = entity_registry.async_get("fan.living_room_fan") + assert fan.unique_id == "00:00:00:00:00:00_1256851357_8" + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + + fan = entity_registry.async_get("fan.ceiling_fan") + assert fan.unique_id == "00:00:00:00:00:00_766313939_8" + + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + # Now change the config to remove one of the fans + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan_one_removed.json" + ) + await device_config_changed(hass, accessories) + + # Verify the first fan is still there + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + # The second fan should have been removed + assert not hass.states.get("fan.ceiling_fan")