diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 202f9694689..561301cdb6d 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -13,7 +13,7 @@ from homeassistant.components.cover import ( SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, - CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, + CONF_IP_ADDRESS, CONF_MODE, CONF_NAME, CONF_PORT, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -25,7 +25,8 @@ from .const import ( CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FILTER, DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO2, DEVICE_CLASS_PM25, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START) -from .util import show_setup_message, validate_entity_config +from .util import ( + show_setup_message, validate_entity_config, validate_media_player_modes) TYPES = Registry() _LOGGER = logging.getLogger(__name__) @@ -125,6 +126,11 @@ def get_accessory(hass, state, aid, config): elif state.domain == 'lock': a_type = 'Lock' + elif state.domain == 'media_player': + validate_media_player_modes(state, config) + if config.get(CONF_MODE): + a_type = 'MediaPlayer' + elif state.domain == 'sensor': unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) device_class = state.attributes.get(ATTR_DEVICE_CLASS) @@ -208,8 +214,8 @@ class HomeKit(): # pylint: disable=unused-variable from . import ( # noqa F401 type_covers, type_fans, type_lights, type_locks, - type_security_systems, type_sensors, type_switches, - type_thermostats) + type_media_players, type_security_systems, type_sensors, + type_switches, type_thermostats) for state in self.hass.states.all(): self.add_bridge_accessory(state) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 21cad2d9cf7..f59ee5488ec 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -23,6 +23,12 @@ BRIDGE_NAME = 'Home Assistant Bridge' BRIDGE_SERIAL_NUMBER = 'homekit.bridge' MANUFACTURER = 'Home Assistant' +# #### Media Player Modes #### +ON_OFF = 'on_off' +PLAY_PAUSE = 'play_pause' +PLAY_STOP = 'play_stop' +TOGGLE_MUTE = 'toggle_mute' + # #### Services #### SERV_ACCESSORY_INFO = 'AccessoryInformation' SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py new file mode 100644 index 00000000000..563cd0cb25c --- /dev/null +++ b/homeassistant/components/homekit/type_media_players.py @@ -0,0 +1,142 @@ +"""Class to hold all media player accessories.""" +import logging + +from pyhap.const import CATEGORY_SWITCH + +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_MODE, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, + STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, DOMAIN) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + CHAR_NAME, CHAR_ON, ON_OFF, PLAY_PAUSE, PLAY_STOP, SERV_SWITCH, + TOGGLE_MUTE) + +_LOGGER = logging.getLogger(__name__) + +MODE_FRIENDLY_NAME = {ON_OFF: 'Power', + PLAY_PAUSE: 'Play/Pause', + PLAY_STOP: 'Play/Stop', + TOGGLE_MUTE: 'Mute'} + + +@TYPES.register('MediaPlayer') +class MediaPlayer(HomeAccessory): + """Generate a Media Player accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self._flag = {ON_OFF: False, PLAY_PAUSE: False, + PLAY_STOP: False, TOGGLE_MUTE: False} + self.chars = {ON_OFF: None, PLAY_PAUSE: None, + PLAY_STOP: None, TOGGLE_MUTE: None} + modes = self.config[CONF_MODE] + + if ON_OFF in modes: + serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_on_off.configure_char( + CHAR_NAME, value=self.generate_service_name(ON_OFF)) + self.chars[ON_OFF] = serv_on_off.configure_char( + CHAR_ON, value=False, setter_callback=self.set_on_off) + + if PLAY_PAUSE in modes: + serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_pause.configure_char( + CHAR_NAME, value=self.generate_service_name(PLAY_PAUSE)) + self.chars[PLAY_PAUSE] = serv_play_pause.configure_char( + CHAR_ON, value=False, setter_callback=self.set_play_pause) + + if PLAY_STOP in modes: + serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_stop.configure_char( + CHAR_NAME, value=self.generate_service_name(PLAY_STOP)) + self.chars[PLAY_STOP] = serv_play_stop.configure_char( + CHAR_ON, value=False, setter_callback=self.set_play_stop) + + if TOGGLE_MUTE in modes: + serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_toggle_mute.configure_char( + CHAR_NAME, value=self.generate_service_name(TOGGLE_MUTE)) + self.chars[TOGGLE_MUTE] = serv_toggle_mute.configure_char( + CHAR_ON, value=False, setter_callback=self.set_toggle_mute) + + def generate_service_name(self, mode): + """Generate name for individual service.""" + return '{} {}'.format(self.display_name, MODE_FRIENDLY_NAME[mode]) + + def set_on_off(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "on_off" to %s', + self.entity_id, value) + self._flag[ON_OFF] = True + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_play_pause(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "play_pause" to %s', + self.entity_id, value) + self._flag[PLAY_PAUSE] = True + service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_play_stop(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "play_stop" to %s', + self.entity_id, value) + self._flag[PLAY_STOP] = True + service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_toggle_mute(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "toggle_mute" to %s', + self.entity_id, value) + self._flag[TOGGLE_MUTE] = True + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_MEDIA_VOLUME_MUTED: value} + self.hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = new_state.state + + if self.chars[ON_OFF]: + hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN, 'None') + if not self._flag[ON_OFF]: + _LOGGER.debug('%s: Set current state for "on_off" to %s', + self.entity_id, hk_state) + self.chars[ON_OFF].set_value(hk_state) + self._flag[ON_OFF] = False + + if self.chars[PLAY_PAUSE]: + hk_state = current_state == STATE_PLAYING + if not self._flag[PLAY_PAUSE]: + _LOGGER.debug('%s: Set current state for "play_pause" to %s', + self.entity_id, hk_state) + self.chars[PLAY_PAUSE].set_value(hk_state) + self._flag[PLAY_PAUSE] = False + + if self.chars[PLAY_STOP]: + hk_state = current_state == STATE_PLAYING + if not self._flag[PLAY_STOP]: + _LOGGER.debug('%s: Set current state for "play_stop" to %s', + self.entity_id, hk_state) + self.chars[PLAY_STOP].set_value(hk_state) + self._flag[PLAY_STOP] = False + + if self.chars[TOGGLE_MUTE]: + current_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) + if not self._flag[TOGGLE_MUTE]: + _LOGGER.debug('%s: Set current state for "toggle_mute" to %s', + self.entity_id, current_state) + self.chars[TOGGLE_MUTE].set_value(current_state) + self._flag[TOGGLE_MUTE] = False diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 447257f9e8f..57ce562ce21 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -3,15 +3,21 @@ import logging import voluptuous as vol +from homeassistant.components.media_player import ( + SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_STOP, SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE) from homeassistant.core import split_entity_id from homeassistant.const import ( - ATTR_CODE, CONF_NAME, TEMP_CELSIUS) + ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_MODE, CONF_NAME, TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv import homeassistant.util.temperature as temp_util -from .const import HOMEKIT_NOTIFY_ID +from .const import ( + HOMEKIT_NOTIFY_ID, ON_OFF, PLAY_PAUSE, PLAY_STOP, TOGGLE_MUTE) _LOGGER = logging.getLogger(__name__) +MEDIA_PLAYER_MODES = (ON_OFF, PLAY_PAUSE, PLAY_STOP, TOGGLE_MUTE) + def validate_entity_config(values): """Validate config entry for CONF_ENTITY.""" @@ -34,10 +40,43 @@ def validate_entity_config(values): code = config.get(ATTR_CODE) params[ATTR_CODE] = cv.string(code) if code else None + if domain == 'media_player': + mode = config.get(CONF_MODE) + params[CONF_MODE] = cv.ensure_list(mode) + for key in params[CONF_MODE]: + if key not in MEDIA_PLAYER_MODES: + raise vol.Invalid( + 'Invalid mode: "{}", valid modes are: "{}".' + .format(key, MEDIA_PLAYER_MODES)) + entities[entity] = params return entities +def validate_media_player_modes(state, config): + """Validate modes for media playeres.""" + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + supported_modes = [] + if features & (SUPPORT_TURN_ON | SUPPORT_TURN_OFF): + supported_modes.append(ON_OFF) + if features & (SUPPORT_PLAY | SUPPORT_PAUSE): + supported_modes.append(PLAY_PAUSE) + if features & (SUPPORT_PLAY | SUPPORT_STOP): + supported_modes.append(PLAY_STOP) + if features & SUPPORT_VOLUME_MUTE: + supported_modes.append(TOGGLE_MUTE) + + if not config.get(CONF_MODE): + config[CONF_MODE] = supported_modes + return + + for mode in config[CONF_MODE]: + if mode not in supported_modes: + raise vol.Invalid('"{}" does not support mode: "{}".' + .format(state.entity_id, mode)) + + def show_setup_message(hass, pincode): """Display persistent notification with setup information.""" pin = pincode.decode() diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 25a0dd3f1cb..6f6d39e477a 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -2,15 +2,20 @@ from unittest.mock import patch, Mock import pytest +import voluptuous as vol from homeassistant.core import State from homeassistant.components.cover import SUPPORT_CLOSE, SUPPORT_OPEN from homeassistant.components.climate import ( SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) +from homeassistant.components.media_player import ( + SUPPORT_TURN_OFF, SUPPORT_TURN_ON) from homeassistant.components.homekit import get_accessory, TYPES +from homeassistant.components.homekit.const import ON_OFF from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_UNIT_OF_MEASUREMENT, CONF_MODE, CONF_NAME, TEMP_CELSIUS, + TEMP_FAHRENHEIT) def test_not_supported(caplog): @@ -24,6 +29,18 @@ def test_not_supported(caplog): assert 'invalid aid' in caplog.records[0].msg +def test_not_supported_media_player(): + """Test if mode isn't supported and if no supported modes.""" + # selected mode for entity not supported + with pytest.raises(vol.Invalid): + entity_state = State('media_player.demo', 'on') + get_accessory(None, entity_state, 2, {CONF_MODE: [ON_OFF]}) + + # no supported modes for entity + entity_state = State('media_player.demo', 'on') + assert get_accessory(None, entity_state, 2, {}) is None + + @pytest.mark.parametrize('config, name', [ ({CONF_NAME: 'Customize Name'}, 'Customize Name'), ]) @@ -40,6 +57,9 @@ def test_customize_options(config, name): ('Fan', 'fan.test', 'on', {}, {}), ('Light', 'light.test', 'on', {}, {}), ('Lock', 'lock.test', 'locked', {}, {ATTR_CODE: '1234'}), + ('MediaPlayer', 'media_player.test', 'on', + {ATTR_SUPPORTED_FEATURES: SUPPORT_TURN_ON | SUPPORT_TURN_OFF}, + {CONF_MODE: [ON_OFF]}), ('SecuritySystem', 'alarm_control_panel.test', 'armed', {}, {ATTR_CODE: '1234'}), ('Thermostat', 'climate.test', 'auto', {}, {}), diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py new file mode 100644 index 00000000000..03135b1418e --- /dev/null +++ b/tests/components/homekit/test_type_media_players.py @@ -0,0 +1,106 @@ +"""Test different accessory types: Media Players.""" + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, DOMAIN) +from homeassistant.components.homekit.type_media_players import MediaPlayer +from homeassistant.components.homekit.const import ( + ON_OFF, PLAY_PAUSE, PLAY_STOP, TOGGLE_MUTE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_MODE, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_VOLUME_MUTE, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, + STATE_PLAYING) + +from tests.common import async_mock_service + + +async def test_media_player_set_state(hass): + """Test if accessory and HA are updated accordingly.""" + config = {CONF_MODE: [ON_OFF, PLAY_PAUSE, PLAY_STOP, TOGGLE_MUTE]} + entity_id = 'media_player.test' + + hass.states.async_set(entity_id, None, {ATTR_SUPPORTED_FEATURES: 20873, + ATTR_MEDIA_VOLUME_MUTED: False}) + await hass.async_block_till_done() + acc = MediaPlayer(hass, 'MediaPlayer', entity_id, 2, config) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 8 # Switch + + assert acc.chars[ON_OFF].value == 0 + assert acc.chars[PLAY_PAUSE].value == 0 + assert acc.chars[PLAY_STOP].value == 0 + assert acc.chars[TOGGLE_MUTE].value == 0 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + await hass.async_block_till_done() + assert acc.chars[ON_OFF].value == 1 + assert acc.chars[TOGGLE_MUTE].value == 1 + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.chars[ON_OFF].value == 0 + + hass.states.async_set(entity_id, STATE_PLAYING) + await hass.async_block_till_done() + assert acc.chars[PLAY_PAUSE].value == 1 + assert acc.chars[PLAY_STOP].value == 1 + + hass.states.async_set(entity_id, STATE_PAUSED) + await hass.async_block_till_done() + assert acc.chars[PLAY_PAUSE].value == 0 + + hass.states.async_set(entity_id, STATE_IDLE) + await hass.async_block_till_done() + assert acc.chars[PLAY_STOP].value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) + call_media_play = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) + call_media_pause = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + call_media_stop = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_STOP) + call_toggle_mute = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_MUTE) + + await hass.async_add_job(acc.chars[ON_OFF].client_update_value, True) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[ON_OFF].client_update_value, False) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[PLAY_PAUSE].client_update_value, True) + await hass.async_block_till_done() + assert call_media_play + assert call_media_play[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[PLAY_PAUSE].client_update_value, False) + await hass.async_block_till_done() + assert call_media_pause + assert call_media_pause[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[PLAY_STOP].client_update_value, True) + await hass.async_block_till_done() + assert call_media_play + assert call_media_play[1].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[PLAY_STOP].client_update_value, False) + await hass.async_block_till_done() + assert call_media_stop + assert call_media_stop[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[TOGGLE_MUTE].client_update_value, True) + await hass.async_block_till_done() + assert call_toggle_mute + assert call_toggle_mute[0].data[ATTR_ENTITY_ID] == entity_id + assert call_toggle_mute[0].data[ATTR_MEDIA_VOLUME_MUTED] is True + + await hass.async_add_job(acc.chars[TOGGLE_MUTE].client_update_value, False) + await hass.async_block_till_done() + assert call_toggle_mute + assert call_toggle_mute[1].data[ATTR_ENTITY_ID] == entity_id + assert call_toggle_mute[1].data[ATTR_MEDIA_VOLUME_MUTED] is False diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 0755e8f54d4..56a625e02d7 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -2,16 +2,20 @@ import pytest import voluptuous as vol -from homeassistant.components.homekit.const import HOMEKIT_NOTIFY_ID +from homeassistant.core import State +from homeassistant.components.homekit.const import ( + HOMEKIT_NOTIFY_ID, ON_OFF, PLAY_PAUSE, PLAY_STOP, TOGGLE_MUTE) from homeassistant.components.homekit.util import ( convert_to_float, density_to_air_quality, dismiss_setup_message, - show_setup_message, temperature_to_homekit, temperature_to_states) + show_setup_message, temperature_to_homekit, temperature_to_states, + validate_media_player_modes) from homeassistant.components.homekit.util import validate_entity_config \ as vec from homeassistant.components.persistent_notification import ( ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN) from homeassistant.const import ( - ATTR_CODE, CONF_NAME, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_MODE, CONF_NAME, STATE_UNKNOWN, + TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import async_mock_service @@ -20,7 +24,8 @@ def test_validate_entity_config(): """Test validate entities.""" configs = [{'invalid_entity_id': {}}, {'demo.test': 1}, {'demo.test': 'test'}, {'demo.test': [1, 2]}, - {'demo.test': None}, {'demo.test': {CONF_NAME: None}}] + {'demo.test': None}, {'demo.test': {CONF_NAME: None}}, + {'media_player.test': {CONF_MODE: 'invalid_mode'}}] for conf in configs: with pytest.raises(vol.Invalid): @@ -39,6 +44,25 @@ def test_validate_entity_config(): assert vec({'lock.demo': {ATTR_CODE: '1234'}}) == \ {'lock.demo': {ATTR_CODE: '1234'}} + assert vec({'media_player.demo': {}}) == \ + {'media_player.demo': {CONF_MODE: []}} + assert vec({'media_player.demo': {CONF_MODE: [ON_OFF]}}) == \ + {'media_player.demo': {CONF_MODE: [ON_OFF]}} + + +def test_validate_media_player_modes(): + """Test validate modes for media players.""" + config = {} + attrs = {ATTR_SUPPORTED_FEATURES: 20873} + entity_state = State('media_player.demo', 'on', attrs) + validate_media_player_modes(entity_state, config) + assert config == {CONF_MODE: [ON_OFF, PLAY_PAUSE, PLAY_STOP, TOGGLE_MUTE]} + + entity_state = State('media_player.demo', 'on') + config = {CONF_MODE: [ON_OFF]} + with pytest.raises(vol.Invalid): + validate_media_player_modes(entity_state, config) + def test_convert_to_float(): """Test convert_to_float method."""