From 4c255677c3cbea2c82b013b0ae3c0eb62e21d726 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 09:36:01 -0500 Subject: [PATCH] Add support for receivers to HomeKit (#100717) --- .../components/homekit/accessories.py | 6 ++-- homeassistant/components/homekit/const.py | 3 ++ homeassistant/components/homekit/strings.json | 4 +-- .../components/homekit/type_media_players.py | 22 +++++++++++++-- .../components/homekit/type_remotes.py | 21 ++++++++------ homeassistant/components/homekit/util.py | 3 +- .../homekit/test_get_accessories.py | 14 ++++++++-- .../homekit/test_type_media_players.py | 28 +++++++++++++++++++ 8 files changed, 83 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index f88047795ca..88422b5c957 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -183,7 +183,9 @@ def get_accessory( # noqa: C901 device_class = state.attributes.get(ATTR_DEVICE_CLASS) feature_list = config.get(CONF_FEATURE_LIST, []) - if device_class == MediaPlayerDeviceClass.TV: + if device_class == MediaPlayerDeviceClass.RECEIVER: + a_type = "ReceiverMediaPlayer" + elif device_class == MediaPlayerDeviceClass.TV: a_type = "TelevisionMediaPlayer" elif validate_media_player_features(state, feature_list): a_type = "MediaPlayer" @@ -274,7 +276,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] aid: int, config: dict, *args: Any, - category: str = CATEGORY_OTHER, + category: int = CATEGORY_OTHER, device_id: str | None = None, **kwargs: Any, ) -> None: diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 81dbf4f7e2e..bb5ae1ffd1c 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -115,6 +115,9 @@ TYPE_SPRINKLER = "sprinkler" TYPE_SWITCH = "switch" TYPE_VALVE = "valve" +# #### Categories #### +CATEGORY_RECEIVER = 34 + # #### Services #### SERV_ACCESSORY_INFO = "AccessoryInformation" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index f57536263ca..30ecfba569e 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -11,7 +11,7 @@ "include_exclude_mode": "Inclusion Mode", "domains": "[%key:component::homekit::config::step::user::data::include_domains%]" }, - "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select mode and domains." }, "accessory": { @@ -57,7 +57,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv/receiver media player, activity based remote, lock, and camera.", "title": "Select domains to be included" }, "pairing": { diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index eae7ed2742a..da7fdceede3 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -1,5 +1,6 @@ """Class to hold all media player accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_SWITCH @@ -36,6 +37,7 @@ from homeassistant.core import callback from .accessories import TYPES, HomeAccessory from .const import ( ATTR_KEY_NAME, + CATEGORY_RECEIVER, CHAR_ACTIVE, CHAR_MUTE, CHAR_NAME, @@ -218,18 +220,20 @@ class MediaPlayer(HomeAccessory): class TelevisionMediaPlayer(RemoteInputSelectAccessory): """Generate a Television Media Player accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a Television Media Player accessory object.""" super().__init__( MediaPlayerEntityFeature.SELECT_SOURCE, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, *args, + **kwargs, ) state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self.chars_speaker = [] + self.chars_speaker: list[str] = [] self._supports_play_pause = features & ( MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE @@ -358,3 +362,17 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): self.char_mute.set_value(current_mute_state) self._async_update_input_state(hk_state, new_state) + + +@TYPES.register("ReceiverMediaPlayer") +class ReceiverMediaPlayer(TelevisionMediaPlayer): + """Generate a Receiver Media Player accessory. + + For HomeKit, a Receiver Media Player is exactly the same as a + Television Media Player except it has a different category + which will tell HomeKit how to render the device. + """ + + def __init__(self, *args: Any) -> None: + """Initialize a Receiver Media Player accessory object.""" + super().__init__(*args, category=CATEGORY_RECEIVER) diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 69441b5ebe1..e440a5b3ac0 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -1,6 +1,7 @@ """Class to hold remote accessories.""" from abc import ABC, abstractmethod import logging +from typing import Any from pyhap.const import CATEGORY_TELEVISION @@ -80,19 +81,21 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): def __init__( self, - required_feature, - source_key, - source_list_key, - *args, - **kwargs, - ): + required_feature: int, + source_key: str, + source_list_key: str, + *args: Any, + category: int = CATEGORY_TELEVISION, + **kwargs: Any, + ) -> None: """Initialize a InputSelect accessory object.""" - super().__init__(*args, category=CATEGORY_TELEVISION, **kwargs) + super().__init__(*args, category=category, **kwargs) state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self._mapped_sources_list = [] - self._mapped_sources = {} + self._mapped_sources_list: list[str] = [] + self._mapped_sources: dict[str, str] = {} self.source_key = source_key self.source_list_key = source_list_key self.sources = [] diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 8287c2b7845..151b97f2cda 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -614,7 +614,8 @@ def state_needs_accessory_mode(state: State) -> bool: return ( state.domain == MEDIA_PLAYER_DOMAIN - and state.attributes.get(ATTR_DEVICE_CLASS) == MediaPlayerDeviceClass.TV + and state.attributes.get(ATTR_DEVICE_CLASS) + in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER) or state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index b57dd2da10f..960647a22e6 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -17,7 +17,10 @@ from homeassistant.components.homekit.const import ( TYPE_SWITCH, TYPE_VALVE, ) -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntityFeature, +) from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( @@ -202,7 +205,14 @@ def test_type_covers(type_name, entity_id, state, attrs) -> None: "TelevisionMediaPlayer", "media_player.tv", "on", - {ATTR_DEVICE_CLASS: "tv"}, + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV}, + {}, + ), + ( + "ReceiverMediaPlayer", + "media_player.receiver", + "on", + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.RECEIVER}, {}, ), ], diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index f68adc24077..3842303ec84 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -1,6 +1,7 @@ """Test different accessory types: Media Players.""" import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, @@ -15,6 +16,7 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.components.homekit.type_media_players import ( MediaPlayer, + ReceiverMediaPlayer, TelevisionMediaPlayer, ) from homeassistant.components.media_player import ( @@ -629,3 +631,29 @@ async def test_media_player_television_unsafe_chars( assert events[-1].data[ATTR_VALUE] is None assert acc.char_input_source.value == 4 + + +async def test_media_player_receiver( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test if television accessory with unsafe characters.""" + entity_id = "media_player.receiver" + sources = ["MUSIC", "HDMI 3/ARC", "SCREEN MIRRORING", "HDMI 2/MHL", "HDMI", "MUSIC"] + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 2/MHL", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + acc = ReceiverMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 34 # Receiver