diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index d94405f23e3a6..b5dd848aa7734 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -95,6 +95,16 @@ def setup(self): continue self._setup_characteristic(char) + accessory = self._accessory.entity_map.aid(self._aid) + this_service = accessory.services.iid(self._iid) + for child_service in accessory.services.filter( + parent_service=this_service + ): + for char in child_service.characteristics: + if char.type not in characteristic_types: + continue + self._setup_characteristic(char.to_accessory_and_service_list()) + def _setup_characteristic(self, char): """Configure an entity based on a HomeKit characteristics metadata.""" # Build up a list of (aid, iid) tuples to poll on update() diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index a84c318b6b3c9..06f2830d5f851 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -8,6 +8,7 @@ AccessoryNotFoundError, EncryptionError, ) +from aiohomekit.model import Accessories from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes @@ -69,9 +70,11 @@ def __init__(self, hass, config_entry, pairing_data): self.pairing_data["AccessoryPairingID"], self.pairing_data ) - self.accessories = {} + self.accessories = None self.config_num = 0 + self.entity_map = Accessories() + # A list of callbacks that turn HK service metadata into entities self.listeners = [] @@ -153,6 +156,8 @@ async def async_setup(self): self.accessories = cache["accessories"] self.config_num = cache["config_num"] + self.entity_map = Accessories.from_list(self.accessories) + self._polling_interval_remover = async_track_time_interval( self.hass, self.async_update, DEFAULT_SCAN_INTERVAL ) @@ -213,6 +218,8 @@ async def async_refresh_entity_map(self, config_num): # later when Bonjour spots c# is still not up to date. return False + self.entity_map = Accessories.from_list(self.accessories) + self.hass.data[ENTITY_MAP].async_create_or_update_map( self.unique_id, config_num, self.accessories ) @@ -318,6 +325,10 @@ def process_new_events(self, new_values_dict): accessory = self.current_state.setdefault(aid, {}) accessory[cid] = value + # self.current_state will be replaced by entity_map in a future PR + # For now we update both + self.entity_map.process_changes(new_values_dict) + self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated) async def get_characteristics(self, *args, **kwargs): diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 5351dfb69cb20..4cfc642bf8c8a 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.17"], + "requirements": ["aiohomekit[IP]==0.2.21"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 7f38dc3ce2a53..38817712deffe 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -7,12 +7,14 @@ RemoteKeyValues, TargetMediaStateValues, ) +from aiohomekit.model.services import ServicesTypes from aiohomekit.utils import clamp_enum_to_char from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerDevice from homeassistant.components.media_player.const import ( SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP, ) from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING @@ -63,8 +65,15 @@ def get_characteristic_types(self): CharacteristicsTypes.CURRENT_MEDIA_STATE, CharacteristicsTypes.TARGET_MEDIA_STATE, CharacteristicsTypes.REMOTE_KEY, + CharacteristicsTypes.ACTIVE_IDENTIFIER, + # Characterics that are on the linked INPUT_SOURCE services + CharacteristicsTypes.CONFIGURED_NAME, + CharacteristicsTypes.IDENTIFIER, ] + def _setup_active_identifier(self, char): + self._features |= SUPPORT_SELECT_SOURCE + def _setup_target_media_state(self, char): self._supported_target_media_state = clamp_enum_to_char( TargetMediaStateValues, char @@ -94,6 +103,43 @@ def supported_features(self): """Flag media player features that are supported.""" return self._features + @property + def source_list(self): + """List of all input sources for this television.""" + sources = [] + + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_sources = this_accessory.services.filter( + service_type=ServicesTypes.INPUT_SOURCE, parent_service=this_tv, + ) + + for input_source in input_sources: + char = input_source[CharacteristicsTypes.CONFIGURED_NAME] + sources.append(char.value) + return sources + + @property + def source(self): + """Name of the current input source.""" + active_identifier = self.get_hk_char_value( + CharacteristicsTypes.ACTIVE_IDENTIFIER + ) + if not active_identifier: + return None + + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_source = this_accessory.services.first( + service_type=ServicesTypes.INPUT_SOURCE, + characteristics={CharacteristicsTypes.IDENTIFIER: active_identifier}, + parent_service=this_tv, + ) + char = input_source[CharacteristicsTypes.CONFIGURED_NAME] + return char.value + @property def state(self): """State of the tv.""" @@ -167,3 +213,28 @@ async def async_media_stop(self): } ] await self._accessory.put_characteristics(characteristics) + + async def async_select_source(self, source): + """Switch to a different media source.""" + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_source = this_accessory.services.first( + service_type=ServicesTypes.INPUT_SOURCE, + characteristics={CharacteristicsTypes.CONFIGURED_NAME: source}, + parent_service=this_tv, + ) + + if not input_source: + raise ValueError(f"Could not find source {source}") + + identifier = input_source[CharacteristicsTypes.IDENTIFIER] + + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["active-identifier"], + "value": identifier.value, + } + ] + await self._accessory.put_characteristics(characteristics) diff --git a/requirements_all.txt b/requirements_all.txt index aa2573253cd11..6b0d1a4278715 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.17 +aiohomekit[IP]==0.2.21 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4b400192de46..685f2e6ad4149 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.17 +aiohomekit[IP]==0.2.21 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 7e27d1a970b67..a1b5f37324d20 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -67,7 +67,7 @@ async def setup_accessories_from_file(hass, path): load_fixture, os.path.join("homekit_controller", path) ) accessories_json = json.loads(accessories_fixture) - accessories = Accessory.setup_accessories_from_list(accessories_json) + accessories = Accessories.from_list(accessories_json) return accessories @@ -153,7 +153,9 @@ async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=N If suffix is set, entityId will include the suffix """ - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) setup_accessory(accessory) domain = None diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py index 69f17ba643126..3ffd906213b7e 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -1,7 +1,11 @@ """Make sure that handling real world LG HomeKit characteristics isn't broken.""" -from homeassistant.components.media_player.const import SUPPORT_PAUSE, SUPPORT_PLAY +from homeassistant.components.media_player.const import ( + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_SELECT_SOURCE, +) from tests.components.homekit_controller.common import ( Helper, @@ -29,8 +33,22 @@ async def test_lg_tv(hass): # Assert that the friendly name is detected correctly assert state.attributes["friendly_name"] == "LG webOS TV AF80" + # Assert that all channels were found and that we know which is active. + assert state.attributes["source_list"] == [ + "AirPlay", + "Live TV", + "HDMI 1", + "Sony", + "Apple", + "AV", + "HDMI 4", + ] + assert state.attributes["source"] == "HDMI 4" + # Assert that all optional features the LS1 supports are detected - assert state.attributes["supported_features"] == (SUPPORT_PAUSE | SUPPORT_PLAY) + assert state.attributes["supported_features"] == ( + SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE + ) device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 2f2554caf8540..760c5f30436b3 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -147,7 +147,7 @@ def setup_mock_accessory(controller): """Add a bridge accessory to a test controller.""" bridge = Accessories() - accessory = Accessory( + accessory = Accessory.create_with_info( name="Koogeek-LS1-20833F", manufacturer="Koogeek", model="LS1", @@ -500,7 +500,9 @@ async def test_user_no_unpaired_devices(hass, controller): async def test_parse_new_homekit_json(hass): """Test migrating recent .homekit/pairings.json files.""" - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) service = accessory.add_service(ServicesTypes.LIGHTBULB) on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 @@ -549,7 +551,9 @@ async def test_parse_new_homekit_json(hass): async def test_parse_old_homekit_json(hass): """Test migrating original .homekit/hk-00:00:00:00:00:00 files.""" - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) service = accessory.add_service(ServicesTypes.LIGHTBULB) on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 @@ -602,7 +606,9 @@ async def test_parse_old_homekit_json(hass): async def test_parse_overlapping_homekit_json(hass): """Test migrating .homekit/pairings.json files when hk- exists too.""" - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) service = accessory.add_service(ServicesTypes.LIGHTBULB) on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index 3389201f61d1f..09798c218a8a4 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -10,6 +10,7 @@ CURRENT_MEDIA_STATE = ("television", "current-media-state") TARGET_MEDIA_STATE = ("television", "target-media-state") REMOTE_KEY = ("television", "remote-key") +ACTIVE_IDENTIFIER = ("television", "active-identifier") def create_tv_service(accessory): @@ -18,16 +19,33 @@ def create_tv_service(accessory): The TV is not currently documented publicly - this is based on observing really TV's that have HomeKit support. """ - service = accessory.add_service(ServicesTypes.TELEVISION) + tv_service = accessory.add_service(ServicesTypes.TELEVISION) - cur_state = service.add_char(CharacteristicsTypes.CURRENT_MEDIA_STATE) + cur_state = tv_service.add_char(CharacteristicsTypes.CURRENT_MEDIA_STATE) cur_state.value = 0 - remote = service.add_char(CharacteristicsTypes.REMOTE_KEY) + remote = tv_service.add_char(CharacteristicsTypes.REMOTE_KEY) remote.value = None remote.perms.append(CharacteristicPermissions.paired_write) - return service + # Add a HDMI 1 channel + input_source_1 = accessory.add_service(ServicesTypes.INPUT_SOURCE) + input_source_1.add_char(CharacteristicsTypes.IDENTIFIER, value=1) + input_source_1.add_char(CharacteristicsTypes.CONFIGURED_NAME, value="HDMI 1") + tv_service.add_linked_service(input_source_1) + + # Add a HDMI 2 channel + input_source_2 = accessory.add_service(ServicesTypes.INPUT_SOURCE) + input_source_2.add_char(CharacteristicsTypes.IDENTIFIER, value=2) + input_source_2.add_char(CharacteristicsTypes.CONFIGURED_NAME, value="HDMI 2") + tv_service.add_linked_service(input_source_2) + + # Support switching channels + active_identifier = tv_service.add_char(CharacteristicsTypes.ACTIVE_IDENTIFIER) + active_identifier.value = 1 + active_identifier.perms.append(CharacteristicPermissions.paired_write) + + return tv_service def create_tv_service_with_target_media_state(accessory): @@ -58,6 +76,15 @@ async def test_tv_read_state(hass, utcnow): assert state.state == "idle" +async def test_tv_read_sources(hass, utcnow): + """Test that we can read the input source of a HomeKit TV.""" + helper = await setup_test_component(hass, create_tv_service) + + state = await helper.poll_and_get_state() + assert state.attributes["source"] == "HDMI 1" + assert state.attributes["source_list"] == ["HDMI 1", "HDMI 2"] + + async def test_play_remote_key(hass, utcnow): """Test that we can play media on a media player.""" helper = await setup_test_component(hass, create_tv_service) @@ -202,3 +229,19 @@ async def test_stop(hass, utcnow): ) assert helper.characteristics[REMOTE_KEY].value is None assert helper.characteristics[TARGET_MEDIA_STATE].value is None + + +async def test_tv_set_source(hass, utcnow): + """Test that we can set the input source of a HomeKit TV.""" + helper = await setup_test_component(hass, create_tv_service) + + await hass.services.async_call( + "media_player", + "select_source", + {"entity_id": "media_player.testdevice", "source": "HDMI 2"}, + blocking=True, + ) + assert helper.characteristics[ACTIVE_IDENTIFIER].value == 2 + + state = await helper.poll_and_get_state() + assert state.attributes["source"] == "HDMI 2"