Skip to content

Commit

Permalink
Implement source switching for homekit_controller televisions (home-a…
Browse files Browse the repository at this point in the history
  • Loading branch information
Jc2k authored Mar 6, 2020
1 parent 0d667c1 commit 2879081
Show file tree
Hide file tree
Showing 10 changed files with 177 additions and 16 deletions.
10 changes: 10 additions & 0 deletions homeassistant/components/homekit_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 12 additions & 1 deletion homeassistant/components/homekit_controller/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
AccessoryNotFoundError,
EncryptionError,
)
from aiohomekit.model import Accessories
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import ServicesTypes

Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/homekit_controller/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
71 changes: 71 additions & 0 deletions homeassistant/components/homekit_controller/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions tests/components/homekit_controller/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions tests/components/homekit_controller/specific_devices/test_lg_tv.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()

Expand Down
14 changes: 10 additions & 4 deletions tests/components/homekit_controller/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 47 additions & 4 deletions tests/components/homekit_controller/test_media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"

0 comments on commit 2879081

Please sign in to comment.