diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 348dd5e7ccf..141800a0b62 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable from datetime import datetime, timedelta +from functools import partial import logging from operator import attrgetter from types import MappingProxyType @@ -144,6 +145,7 @@ 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]] = {} @property @@ -605,6 +607,8 @@ class HKDevice: 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() await self.async_add_new_entities() @@ -805,6 +809,16 @@ class HKDevice: for callback_ in to_callback: callback_() + @callback + def _remove_characteristics_callback( + self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE + ) -> None: + """Remove a characteristics callback.""" + for aid_iid in characteristics: + self._subscriptions[aid_iid].remove(callback_) + if not self._subscriptions[aid_iid]: + del self._subscriptions[aid_iid] + @callback def async_subscribe( self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE @@ -812,24 +826,31 @@ class HKDevice: """Add characteristics to the watch list.""" for aid_iid in characteristics: self._subscriptions.setdefault(aid_iid, set()).add(callback_) + return partial( + self._remove_characteristics_callback, characteristics, callback_ + ) - def _unsub(): - for aid_iid in characteristics: - self._subscriptions[aid_iid].remove(callback_) - if not self._subscriptions[aid_iid]: - del self._subscriptions[aid_iid] - - return _unsub + @callback + def _remove_availability_callback(self, callback_: CALLBACK_TYPE) -> None: + """Remove an availability callback.""" + self._availability_callbacks.remove(callback_) @callback def async_subscribe_availability(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: """Add characteristics to the watch list.""" self._availability_callbacks.add(callback_) + return partial(self._remove_availability_callback, callback_) - def _unsub(): - self._availability_callbacks.remove(callback_) + @callback + def _remove_config_changed_callback(self, callback_: CALLBACK_TYPE) -> None: + """Remove an availability callback.""" + self._config_changed_callbacks.remove(callback_) - return _unsub + @callback + def async_subscribe_config_changed(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: + """Subscribe to config of the accessory being changed aka c# changes.""" + self._config_changed_callbacks.add(callback_) + return partial(self._remove_config_changed_callback, callback_) async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]: """Read latest state from homekit accessory.""" diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 6fdb450a5b4..04dabf410a4 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -3,15 +3,15 @@ from __future__ import annotations from typing import Any -from aiohomekit.model import Accessory from aiohomekit.model.characteristics import ( EVENT_CHARACTERISTICS, Characteristic, CharacteristicPermissions, CharacteristicsTypes, ) -from aiohomekit.model.services import Service, ServicesTypes +from aiohomekit.model.services import ServicesTypes +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType @@ -32,41 +32,43 @@ class HomeKitEntity(Entity): 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() super().__init__() - @property - def accessory(self) -> Accessory: - """Return an Accessory model that this entity is attached to.""" - return self._accessory.entity_map.aid(self._aid) - - @property - def accessory_info(self) -> Service: - """Information about the make and model of an accessory.""" - return self.accessory.services.first( + @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 ) - @property - def service(self) -> Service: - """Return a Service model that this entity is attached to.""" - return self.accessory.services.iid(self._iid) + @callback + def _async_config_changed(self) -> None: + """Handle accessory discovery changes.""" + self._async_set_accessory_and_service() + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Entity added to hass.""" + accessory = self._accessory self.async_on_remove( - self._accessory.async_subscribe( + accessory.async_subscribe( self.all_characteristics, self._async_write_ha_state ) ) self.async_on_remove( - self._accessory.async_subscribe_availability(self._async_write_ha_state) + accessory.async_subscribe_availability(self._async_write_ha_state) ) - self._accessory.add_pollable_characteristics(self.pollable_characteristics) - await self._accessory.add_watchable_characteristics( - self.watchable_characteristics + 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.""" diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 2b532769220..4fbbfea932f 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -8,6 +8,7 @@ import os from typing import Any, Final from unittest import mock +from aiohomekit.controller.abstract import AbstractPairing from aiohomekit.hkjson import loads as hkloads from aiohomekit.model import ( Accessories, @@ -180,7 +181,7 @@ async def time_changed(hass, seconds): await hass.async_block_till_done() -async def setup_accessories_from_file(hass, path): +async def setup_accessories_from_file(hass: HomeAssistant, path: str) -> Accessories: """Load an collection of accessory defs from JSON data.""" accessories_fixture = await hass.async_add_executor_job( load_fixture, os.path.join("homekit_controller", path) @@ -242,11 +243,11 @@ async def setup_test_accessories_with_controller( return config_entry, pairing -async def device_config_changed(hass, accessories): +async def device_config_changed(hass: HomeAssistant, accessories: Accessories): """Discover new devices added to Home Assistant at runtime.""" # Update the accessories our FakePairing knows about controller = hass.data[CONTROLLER] - pairing = controller.pairings["00:00:00:00:00:00"] + pairing: AbstractPairing = controller.pairings["00:00:00:00:00:00"] accessories_obj = Accessories() for accessory in accessories: diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json new file mode 100644 index 00000000000..e508a3523c4 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json @@ -0,0 +1,244 @@ +[ + { + "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": "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" + } + ] + }, + { + "aid": 766313939, + "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": "Ceiling Fan" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "fan.ceiling_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": "RotationSpeed", + "format": "float", + "iid": 10, + "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 aa9294472f0..d02aaa1ae49 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -5544,6 +5544,292 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_basic_fan] + 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:766313939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Ceiling 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.ceiling_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan Identify', + }), + 'entity_id': 'button.ceiling_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.ceiling_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.ceiling_fan', + 'state': 'off', + }), + }), + ]), + }), + 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', + '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[home_assistant_bridge_fan] list([ dict({ 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 new file mode 100644 index 00000000000..a7750edf9aa --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py @@ -0,0 +1,55 @@ +"""Test for a Home Assistant bridge that changes fan features at runtime.""" + + +from homeassistant.components.fan import FanEntityFeature +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: + """Test that new features can be added 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_basic_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 + ) + + 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_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 + | FanEntityFeature.OSCILLATE + ) + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED