From 02d8731a6126388da5af2dc011783f2766acf769 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 May 2019 11:39:42 -0500 Subject: [PATCH] Add HEOS sign-in/out services (#23729) * Add HEOS sign-in/out services * Fix typo in comment --- homeassistant/components/heos/__init__.py | 13 ++- homeassistant/components/heos/const.py | 4 + homeassistant/components/heos/services.py | 66 ++++++++++++++ homeassistant/components/heos/services.yaml | 12 +++ homeassistant/core.py | 3 + tests/components/heos/conftest.py | 1 + tests/components/heos/test_init.py | 5 +- tests/components/heos/test_services.py | 98 +++++++++++++++++++++ tests/test_core.py | 22 +++++ 9 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/heos/services.py create mode 100644 homeassistant/components/heos/services.yaml create mode 100644 tests/components/heos/test_services.py diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 891f280511c..6585393d12e 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -15,6 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util import Throttle +from . import services from .config_flow import format_title from .const import ( COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER_MANAGER, @@ -81,8 +82,10 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): if controller.is_signed_in: favorites = await controller.get_favorites() else: - _LOGGER.warning("%s is not logged in to your HEOS account and will" - " be unable to retrieve your favorites", host) + _LOGGER.warning( + "%s is not logged in to a HEOS account and will be unable " + "to retrieve HEOS favorites: Use the 'heos.sign_in' service " + "to sign-in to a HEOS account", host) inputs = await controller.get_input_sources() except (asyncio.TimeoutError, ConnectionError, CommandError) as error: await controller.disconnect() @@ -101,6 +104,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): DATA_SOURCE_MANAGER: source_manager, MEDIA_PLAYER_DOMAIN: players } + + services.register(hass, controller) + hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, MEDIA_PLAYER_DOMAIN)) return True @@ -111,6 +117,9 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER] await controller_manager.disconnect() hass.data.pop(DOMAIN) + + services.remove(hass) + return await hass.config_entries.async_forward_entry_unload( entry, MEDIA_PLAYER_DOMAIN) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 6a1a2ae8182..d3e3ccb07c3 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -1,9 +1,13 @@ """Const for the HEOS integration.""" +ATTR_PASSWORD = "password" +ATTR_USERNAME = "username" COMMAND_RETRY_ATTEMPTS = 2 COMMAND_RETRY_DELAY = 1 DATA_CONTROLLER_MANAGER = "controller" DATA_SOURCE_MANAGER = "source_manager" DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" DOMAIN = 'heos' +SERVICE_SIGN_IN = "sign_in" +SERVICE_SIGN_OUT = "sign_out" SIGNAL_HEOS_UPDATED = "heos_updated" diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py new file mode 100644 index 00000000000..5b998f384dc --- /dev/null +++ b/homeassistant/components/heos/services.py @@ -0,0 +1,66 @@ +"""Services for the HEOS integration.""" +import asyncio +import functools +import logging + +from pyheos import CommandError, Heos, const +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + ATTR_PASSWORD, ATTR_USERNAME, DOMAIN, SERVICE_SIGN_IN, SERVICE_SIGN_OUT) + +_LOGGER = logging.getLogger(__name__) + +HEOS_SIGN_IN_SCHEMA = vol.Schema({ + vol.Required(ATTR_USERNAME): cv.string, + vol.Required(ATTR_PASSWORD): cv.string +}) + +HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) + + +def register(hass: HomeAssistantType, controller: Heos): + """Register HEOS services.""" + hass.services.async_register( + DOMAIN, SERVICE_SIGN_IN, + functools.partial(_sign_in_handler, controller), + schema=HEOS_SIGN_IN_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_SIGN_OUT, + functools.partial(_sign_out_handler, controller), + schema=HEOS_SIGN_OUT_SCHEMA) + + +def remove(hass: HomeAssistantType): + """Unregister HEOS services.""" + hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN) + hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT) + + +async def _sign_in_handler(controller, service): + """Sign in to the HEOS account.""" + if controller.connection_state != const.STATE_CONNECTED: + _LOGGER.error("Unable to sign in because HEOS is not connected") + return + username = service.data[ATTR_USERNAME] + password = service.data[ATTR_PASSWORD] + try: + await controller.sign_in(username, password) + except CommandError as err: + _LOGGER.error("Sign in failed: %s", err) + except (asyncio.TimeoutError, ConnectionError) as err: + _LOGGER.error("Unable to sign in: %s", err) + + +async def _sign_out_handler(controller, service): + """Sign out of the HEOS account.""" + if controller.connection_state != const.STATE_CONNECTED: + _LOGGER.error("Unable to sign out because HEOS is not connected") + return + try: + await controller.sign_out() + except (asyncio.TimeoutError, ConnectionError, CommandError) as err: + _LOGGER.error("Unable to sign out: %s", err) diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml new file mode 100644 index 00000000000..8274240368f --- /dev/null +++ b/homeassistant/components/heos/services.yaml @@ -0,0 +1,12 @@ +sign_in: + description: Sign the controller in to a HEOS account. + fields: + username: + description: The username or email of the HEOS account. [Required] + example: 'example@example.com' + password: + description: The password of the HEOS account. [Required] + example: 'password' + +sign_out: + description: Sign the controller out of the HEOS account. \ No newline at end of file diff --git a/homeassistant/core.py b/homeassistant/core.py index 6dd713e9f0b..c127e100f11 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -936,6 +936,9 @@ class Service: """Initialize a service.""" self.func = func self.schema = schema + # Properly detect wrapped functions + while isinstance(func, functools.partial): + func = func.func self.is_callback = is_callback(func) self.is_coroutinefunction = asyncio.iscoroutinefunction(func) diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 22047aac6ca..11a2ece3442 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -35,6 +35,7 @@ def controller_fixture( mock_heos.load_players.return_value = change_data mock_heos.is_signed_in = True mock_heos.signed_in_username = "user@user.com" + mock_heos.connection_state = const.STATE_CONNECTED yield mock_heos diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 72716bc3138..b709c89121a 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -102,8 +102,9 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( assert hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN] == controller.players assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].favorites == {} assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].inputs == input_sources - assert "127.0.0.1 is not logged in to your HEOS account and will be " \ - "unable to retrieve your favorites" in caplog.text + assert "127.0.0.1 is not logged in to a HEOS account and will be unable " \ + "to retrieve HEOS favorites: Use the 'heos.sign_in' service to " \ + "sign-in to a HEOS account" in caplog.text async def test_async_setup_entry_connect_failure( diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py new file mode 100644 index 00000000000..ad64eaa34ea --- /dev/null +++ b/tests/components/heos/test_services.py @@ -0,0 +1,98 @@ +"""Tests for the services module.""" +from pyheos import CommandError, const + +from homeassistant.components.heos.const import ( + ATTR_PASSWORD, ATTR_USERNAME, DOMAIN, SERVICE_SIGN_IN, SERVICE_SIGN_OUT) +from homeassistant.setup import async_setup_component + + +async def setup_component(hass, config_entry): + """Set up the component for testing.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +async def test_sign_in(hass, config_entry, controller): + """Test the sign-in service.""" + await setup_component(hass, config_entry) + + await hass.services.async_call( + DOMAIN, SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True) + + controller.sign_in.assert_called_once_with("test@test.com", "password") + + +async def test_sign_in_not_connected(hass, config_entry, controller, caplog): + """Test sign-in service logs error when not connected.""" + await setup_component(hass, config_entry) + controller.connection_state = const.STATE_RECONNECTING + + await hass.services.async_call( + DOMAIN, SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True) + + assert controller.sign_in.call_count == 0 + assert "Unable to sign in because HEOS is not connected" in caplog.text + + +async def test_sign_in_failed(hass, config_entry, controller, caplog): + """Test sign-in service logs error when not connected.""" + await setup_component(hass, config_entry) + controller.sign_in.side_effect = CommandError("", "Invalid credentials", 6) + + await hass.services.async_call( + DOMAIN, SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True) + + controller.sign_in.assert_called_once_with("test@test.com", "password") + assert "Sign in failed: Invalid credentials (6)" in caplog.text + + +async def test_sign_in_unknown_error(hass, config_entry, controller, caplog): + """Test sign-in service logs error for failure.""" + await setup_component(hass, config_entry) + controller.sign_in.side_effect = ConnectionError + + await hass.services.async_call( + DOMAIN, SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True) + + controller.sign_in.assert_called_once_with("test@test.com", "password") + assert "Unable to sign in" in caplog.text + + +async def test_sign_out(hass, config_entry, controller): + """Test the sign-out service.""" + await setup_component(hass, config_entry) + + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) + + assert controller.sign_out.call_count == 1 + + +async def test_sign_out_not_connected(hass, config_entry, controller, caplog): + """Test the sign-out service.""" + await setup_component(hass, config_entry) + controller.connection_state = const.STATE_RECONNECTING + + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) + + assert controller.sign_out.call_count == 0 + assert "Unable to sign out because HEOS is not connected" in caplog.text + + +async def test_sign_out_unknown_error(hass, config_entry, controller, caplog): + """Test the sign-out service.""" + await setup_component(hass, config_entry) + controller.sign_out.side_effect = ConnectionError + + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) + + assert controller.sign_out.call_count == 1 + assert "Unable to sign out" in caplog.text diff --git a/tests/test_core.py b/tests/test_core.py index cdcf30fa8b3..1e709ed3a8a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -744,6 +744,28 @@ class TestServiceRegistry(unittest.TestCase): self.hass.block_till_done() assert 1 == len(calls) + def test_async_service_partial(self): + """Test registering and calling an wrapped async service.""" + calls = [] + + async def service_handler(call): + """Service handler coroutine.""" + calls.append(call) + + self.services.register( + 'test_domain', 'register_calls', + functools.partial(service_handler)) + self.hass.block_till_done() + + assert len(self.calls_register) == 1 + assert self.calls_register[-1].data['domain'] == 'test_domain' + assert self.calls_register[-1].data['service'] == 'register_calls' + + assert self.services.call('test_domain', 'REGISTER_CALLS', + blocking=True) + self.hass.block_till_done() + assert len(calls) == 1 + def test_callback_service(self): """Test registering and calling an async service.""" calls = []