Skip to content

Commit

Permalink
Add HEOS sign-in/out services (home-assistant#23729)
Browse files Browse the repository at this point in the history
* Add HEOS sign-in/out services

* Fix typo in comment
  • Loading branch information
andrewsayre authored and balloob committed May 7, 2019
1 parent 102beaa commit 02d8731
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 4 deletions.
13 changes: 11 additions & 2 deletions homeassistant/components/heos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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,
Expand Down Expand Up @@ -81,8 +82,10 @@ async def disconnect_controller(event):
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()
Expand All @@ -101,6 +104,9 @@ async def disconnect_controller(event):
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
Expand All @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/heos/const.py
Original file line number Diff line number Diff line change
@@ -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"
66 changes: 66 additions & 0 deletions homeassistant/components/heos/services.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions homeassistant/components/heos/services.yaml
Original file line number Diff line number Diff line change
@@ -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: '[email protected]'
password:
description: The password of the HEOS account. [Required]
example: 'password'

sign_out:
description: Sign the controller out of the HEOS account.
3 changes: 3 additions & 0 deletions homeassistant/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,9 @@ def __init__(self, func: Callable, schema: Optional[vol.Schema],
"""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)

Expand Down
1 change: 1 addition & 0 deletions tests/components/heos/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]"
mock_heos.connection_state = const.STATE_CONNECTED
yield mock_heos


Expand Down
5 changes: 3 additions & 2 deletions tests/components/heos/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
98 changes: 98 additions & 0 deletions tests/components/heos/test_services.py
Original file line number Diff line number Diff line change
@@ -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: "[email protected]", ATTR_PASSWORD: "password"},
blocking=True)

controller.sign_in.assert_called_once_with("[email protected]", "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: "[email protected]", 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: "[email protected]", ATTR_PASSWORD: "password"},
blocking=True)

controller.sign_in.assert_called_once_with("[email protected]", "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: "[email protected]", ATTR_PASSWORD: "password"},
blocking=True)

controller.sign_in.assert_called_once_with("[email protected]", "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
22 changes: 22 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,28 @@ async def service_handler(call):
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 = []
Expand Down

0 comments on commit 02d8731

Please sign in to comment.