Add HEOS sign-in/out services (#23729)
* Add HEOS sign-in/out services * Fix typo in comment
This commit is contained in:
parent
102beaa044
commit
02d8731a61
9 changed files with 220 additions and 4 deletions
|
@ -15,6 +15,7 @@ import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
|
||||||
|
from . import services
|
||||||
from .config_flow import format_title
|
from .config_flow import format_title
|
||||||
from .const import (
|
from .const import (
|
||||||
COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER_MANAGER,
|
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:
|
if controller.is_signed_in:
|
||||||
favorites = await controller.get_favorites()
|
favorites = await controller.get_favorites()
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning("%s is not logged in to your HEOS account and will"
|
_LOGGER.warning(
|
||||||
" be unable to retrieve your favorites", host)
|
"%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()
|
inputs = await controller.get_input_sources()
|
||||||
except (asyncio.TimeoutError, ConnectionError, CommandError) as error:
|
except (asyncio.TimeoutError, ConnectionError, CommandError) as error:
|
||||||
await controller.disconnect()
|
await controller.disconnect()
|
||||||
|
@ -101,6 +104,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
|
||||||
DATA_SOURCE_MANAGER: source_manager,
|
DATA_SOURCE_MANAGER: source_manager,
|
||||||
MEDIA_PLAYER_DOMAIN: players
|
MEDIA_PLAYER_DOMAIN: players
|
||||||
}
|
}
|
||||||
|
|
||||||
|
services.register(hass, controller)
|
||||||
|
|
||||||
hass.async_create_task(hass.config_entries.async_forward_entry_setup(
|
hass.async_create_task(hass.config_entries.async_forward_entry_setup(
|
||||||
entry, MEDIA_PLAYER_DOMAIN))
|
entry, MEDIA_PLAYER_DOMAIN))
|
||||||
return True
|
return True
|
||||||
|
@ -111,6 +117,9 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
|
||||||
controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER]
|
controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER]
|
||||||
await controller_manager.disconnect()
|
await controller_manager.disconnect()
|
||||||
hass.data.pop(DOMAIN)
|
hass.data.pop(DOMAIN)
|
||||||
|
|
||||||
|
services.remove(hass)
|
||||||
|
|
||||||
return await hass.config_entries.async_forward_entry_unload(
|
return await hass.config_entries.async_forward_entry_unload(
|
||||||
entry, MEDIA_PLAYER_DOMAIN)
|
entry, MEDIA_PLAYER_DOMAIN)
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
"""Const for the HEOS integration."""
|
"""Const for the HEOS integration."""
|
||||||
|
|
||||||
|
ATTR_PASSWORD = "password"
|
||||||
|
ATTR_USERNAME = "username"
|
||||||
COMMAND_RETRY_ATTEMPTS = 2
|
COMMAND_RETRY_ATTEMPTS = 2
|
||||||
COMMAND_RETRY_DELAY = 1
|
COMMAND_RETRY_DELAY = 1
|
||||||
DATA_CONTROLLER_MANAGER = "controller"
|
DATA_CONTROLLER_MANAGER = "controller"
|
||||||
DATA_SOURCE_MANAGER = "source_manager"
|
DATA_SOURCE_MANAGER = "source_manager"
|
||||||
DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
|
DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
|
||||||
DOMAIN = 'heos'
|
DOMAIN = 'heos'
|
||||||
|
SERVICE_SIGN_IN = "sign_in"
|
||||||
|
SERVICE_SIGN_OUT = "sign_out"
|
||||||
SIGNAL_HEOS_UPDATED = "heos_updated"
|
SIGNAL_HEOS_UPDATED = "heos_updated"
|
||||||
|
|
66
homeassistant/components/heos/services.py
Normal file
66
homeassistant/components/heos/services.py
Normal file
|
@ -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
homeassistant/components/heos/services.yaml
Normal file
12
homeassistant/components/heos/services.yaml
Normal file
|
@ -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.
|
|
@ -936,6 +936,9 @@ class Service:
|
||||||
"""Initialize a service."""
|
"""Initialize a service."""
|
||||||
self.func = func
|
self.func = func
|
||||||
self.schema = schema
|
self.schema = schema
|
||||||
|
# Properly detect wrapped functions
|
||||||
|
while isinstance(func, functools.partial):
|
||||||
|
func = func.func
|
||||||
self.is_callback = is_callback(func)
|
self.is_callback = is_callback(func)
|
||||||
self.is_coroutinefunction = asyncio.iscoroutinefunction(func)
|
self.is_coroutinefunction = asyncio.iscoroutinefunction(func)
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,7 @@ def controller_fixture(
|
||||||
mock_heos.load_players.return_value = change_data
|
mock_heos.load_players.return_value = change_data
|
||||||
mock_heos.is_signed_in = True
|
mock_heos.is_signed_in = True
|
||||||
mock_heos.signed_in_username = "user@user.com"
|
mock_heos.signed_in_username = "user@user.com"
|
||||||
|
mock_heos.connection_state = const.STATE_CONNECTED
|
||||||
yield mock_heos
|
yield mock_heos
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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][MEDIA_PLAYER_DOMAIN] == controller.players
|
||||||
assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].favorites == {}
|
assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].favorites == {}
|
||||||
assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].inputs == input_sources
|
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 " \
|
assert "127.0.0.1 is not logged in to a HEOS account and will be unable " \
|
||||||
"unable to retrieve your favorites" in caplog.text
|
"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(
|
async def test_async_setup_entry_connect_failure(
|
||||||
|
|
98
tests/components/heos/test_services.py
Normal file
98
tests/components/heos/test_services.py
Normal file
|
@ -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
|
|
@ -744,6 +744,28 @@ class TestServiceRegistry(unittest.TestCase):
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
assert 1 == len(calls)
|
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):
|
def test_callback_service(self):
|
||||||
"""Test registering and calling an async service."""
|
"""Test registering and calling an async service."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue