Add config entry for Sonos + Cast (#14955)

* Add config entry for Sonos

* Lint

* Use add_job

* Add Cast config entry

* Lint

* Rename DOMAIN import

* Mock pychromecast in test
This commit is contained in:
Paulus Schoutsen 2018-06-14 15:17:54 -04:00 committed by GitHub
parent c8e0de19b6
commit 2c6e6c2a6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 432 additions and 10 deletions

View File

@ -61,6 +61,9 @@ omit =
homeassistant/components/coinbase.py
homeassistant/components/sensor/coinbase.py
homeassistant/components/cast/*
homeassistant/components/*/cast.py
homeassistant/components/comfoconnect.py
homeassistant/components/*/comfoconnect.py
@ -252,6 +255,9 @@ omit =
homeassistant/components/smappee.py
homeassistant/components/*/smappee.py
homeassistant/components/sonos/__init__.py
homeassistant/components/*/sonos.py
homeassistant/components/tado.py
homeassistant/components/*/tado.py
@ -482,7 +488,6 @@ omit =
homeassistant/components/media_player/aquostv.py
homeassistant/components/media_player/bluesound.py
homeassistant/components/media_player/braviatv.py
homeassistant/components/media_player/cast.py
homeassistant/components/media_player/channels.py
homeassistant/components/media_player/clementine.py
homeassistant/components/media_player/cmus.py
@ -518,7 +523,6 @@ omit =
homeassistant/components/media_player/russound_rnet.py
homeassistant/components/media_player/snapcast.py
homeassistant/components/media_player/songpal.py
homeassistant/components/media_player/sonos.py
homeassistant/components/media_player/spotify.py
homeassistant/components/media_player/squeezebox.py
homeassistant/components/media_player/ue_smart_radio.py

View File

@ -0,0 +1,15 @@
{
"config": {
"abort": {
"no_devices_found": "No Google Cast devices found on the network.",
"single_instance_allowed": "Only a single configuration of Google Cast is necessary."
},
"step": {
"confirm": {
"description": "Do you want to setup Google Cast?",
"title": "Google Cast"
}
},
"title": "Google Cast"
}
}

View File

@ -0,0 +1,30 @@
"""Component to embed Google Cast."""
from homeassistant.helpers import config_entry_flow
DOMAIN = 'cast'
REQUIREMENTS = ['pychromecast==2.1.0']
async def async_setup(hass, config):
"""Set up the Cast component."""
hass.data[DOMAIN] = config.get(DOMAIN, {})
return True
async def async_setup_entry(hass, entry):
"""Set up Cast from a config entry."""
hass.async_add_job(hass.config_entries.async_forward_entry_setup(
entry, 'media_player'))
return True
async def _async_has_devices(hass):
"""Return if there are devices that can be discovered."""
from pychromecast.discovery import discover_chromecasts
return await hass.async_add_job(discover_chromecasts)
config_entry_flow.register_discovery_flow(
DOMAIN, 'Google Cast', _async_has_devices)

View File

@ -0,0 +1,15 @@
{
"config": {
"title": "Google Cast",
"step": {
"confirm": {
"title": "Google Cast",
"description": "Do you want to setup Google Cast?"
}
},
"abort": {
"single_instance_allowed": "Only a single configuration of Google Cast is necessary.",
"no_devices_found": "No Google Cast devices found on the network."
}
}
}

View File

@ -46,7 +46,9 @@ SERVICE_HOMEKIT = 'homekit'
CONFIG_ENTRY_HANDLERS = {
SERVICE_DECONZ: 'deconz',
'google_cast': 'cast',
SERVICE_HUE: 'hue',
'sonos': 'sonos',
}
SERVICE_HANDLERS = {
@ -64,11 +66,9 @@ SERVICE_HANDLERS = {
SERVICE_SABNZBD: ('sabnzbd', None),
SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'),
SERVICE_KONNECTED: ('konnected', None),
'google_cast': ('media_player', 'cast'),
'panasonic_viera': ('media_player', 'panasonic_viera'),
'plex_mediaserver': ('media_player', 'plex'),
'roku': ('media_player', 'roku'),
'sonos': ('media_player', 'sonos'),
'yamaha': ('media_player', 'yamaha'),
'logitech_mediaserver': ('media_player', 'squeezebox'),
'directv': ('media_player', 'directv'),

View File

@ -456,6 +456,16 @@ async def async_setup(hass, config):
return True
async def async_setup_entry(hass, entry):
"""Setup a config entry."""
return await hass.data[DOMAIN].async_setup_entry(entry)
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
return await hass.data[DOMAIN].async_unload_entry(entry)
class MediaPlayerDevice(Entity):
"""ABC for media player devices."""

View File

@ -17,6 +17,7 @@ from homeassistant.helpers.typing import HomeAssistantType, ConfigType
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (dispatcher_send,
async_dispatcher_connect)
from homeassistant.components.cast import DOMAIN as CAST_DOMAIN
from homeassistant.components.media_player import (
MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
@ -28,7 +29,7 @@ from homeassistant.const import (
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
REQUIREMENTS = ['pychromecast==2.1.0']
DEPENDENCIES = ('cast',)
_LOGGER = logging.getLogger(__name__)
@ -186,6 +187,26 @@ def _async_create_cast_device(hass: HomeAssistantType,
async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
async_add_devices, discovery_info=None):
"""Set up thet Cast platform.
Deprecated.
"""
_LOGGER.warning(
'Setting configuration for Cast via platform is deprecated. '
'Configure via Cast component instead.')
await _async_setup_platform(
hass, config, async_add_devices, discovery_info)
async def async_setup_entry(hass, config_entry, async_add_devices):
"""Set up Cast from a config entry."""
await _async_setup_platform(
hass, hass.data[CAST_DOMAIN].get('media_player', {}),
async_add_devices, None)
async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType,
async_add_devices, discovery_info):
"""Set up the cast platform."""
import pychromecast

View File

@ -20,13 +20,14 @@ from homeassistant.components.media_player import (
SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP,
SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice)
from homeassistant.components.sonos import DOMAIN as SONOS_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_TIME, CONF_HOSTS, STATE_IDLE, STATE_OFF, STATE_PAUSED,
STATE_PLAYING)
import homeassistant.helpers.config_validation as cv
from homeassistant.util.dt import utcnow
REQUIREMENTS = ['SoCo==0.14']
DEPENDENCIES = ('sonos',)
_LOGGER = logging.getLogger(__name__)
@ -49,7 +50,7 @@ SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer'
SERVICE_UPDATE_ALARM = 'sonos_update_alarm'
SERVICE_SET_OPTION = 'sonos_set_option'
DATA_SONOS = 'sonos'
DATA_SONOS = 'sonos_devices'
SOURCE_LINEIN = 'Line-in'
SOURCE_TV = 'TV'
@ -118,6 +119,26 @@ class SonosData:
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Sonos platform.
Deprecated.
"""
_LOGGER.warning('Loading Sonos via platform config is deprecated.')
_setup_platform(hass, config, add_devices, discovery_info)
async def async_setup_entry(hass, config_entry, async_add_devices):
"""Set up Sonos from a config entry."""
def add_devices(devices, update_before_add=False):
"""Sync version of async add devices."""
hass.add_job(async_add_devices, devices, update_before_add)
hass.add_job(_setup_platform, hass,
hass.data[SONOS_DOMAIN].get('media_player', {}),
add_devices, None)
def _setup_platform(hass, config, add_devices, discovery_info):
"""Set up the Sonos platform."""
import soco
import soco.events

View File

@ -0,0 +1,15 @@
{
"config": {
"abort": {
"no_devices_found": "No Sonos devices found on the network.",
"single_instance_allowed": "Only a single configuration of Sonos is necessary."
},
"step": {
"confirm": {
"description": "Do you want to setup Sonos?",
"title": "Sonos"
}
},
"title": "Sonos"
}
}

View File

@ -0,0 +1,29 @@
"""Component to embed Sonos."""
from homeassistant.helpers import config_entry_flow
DOMAIN = 'sonos'
REQUIREMENTS = ['SoCo==0.14']
async def async_setup(hass, config):
"""Set up the Sonos component."""
hass.data[DOMAIN] = config.get(DOMAIN, {})
return True
async def async_setup_entry(hass, entry):
"""Set up Sonos from a config entry."""
hass.async_add_job(hass.config_entries.async_forward_entry_setup(
entry, 'media_player'))
return True
async def _async_has_devices(hass):
"""Return if there are devices that can be discovered."""
import soco
return await hass.async_add_job(soco.discover)
config_entry_flow.register_discovery_flow(DOMAIN, 'Sonos', _async_has_devices)

View File

@ -0,0 +1,15 @@
{
"config": {
"title": "Sonos",
"step": {
"confirm": {
"title": "Sonos",
"description": "Do you want to setup Sonos?"
}
},
"abort": {
"single_instance_allowed": "Only a single configuration of Sonos is necessary.",
"no_devices_found": "No Sonos devices found on the network."
}
}
}

View File

@ -127,9 +127,11 @@ _LOGGER = logging.getLogger(__name__)
HANDLERS = Registry()
# Components that have config flows. In future we will auto-generate this list.
FLOWS = [
'cast',
'deconz',
'hue',
'nest',
'sonos',
'zone',
]

View File

@ -0,0 +1,85 @@
"""Helpers for data entry flows for config entries."""
from functools import partial
from homeassistant.core import callback
from homeassistant import config_entries, data_entry_flow
def register_discovery_flow(domain, title, discovery_function):
"""Register flow for discovered integrations that not require auth."""
config_entries.HANDLERS.register(domain)(
partial(DiscoveryFlowHandler, domain, title, discovery_function))
class DiscoveryFlowHandler(data_entry_flow.FlowHandler):
"""Handle a discovery config flow."""
VERSION = 1
def __init__(self, domain, title, discovery_function):
"""Initialize the discovery config flow."""
self._domain = domain
self._title = title
self._discovery_function = discovery_function
async def async_step_init(self, user_input=None):
"""Handle a flow initialized by the user."""
if self._async_current_entries():
return self.async_abort(
reason='single_instance_allowed'
)
# Get current discovered entries.
in_progress = self._async_in_progress()
has_devices = in_progress
if not has_devices:
has_devices = await self.hass.async_add_job(
self._discovery_function, self.hass)
if not has_devices:
return self.async_abort(
reason='no_devices_found'
)
# Cancel the discovered one.
for flow in in_progress:
self.hass.config_entries.flow.async_abort(flow['flow_id'])
return self.async_create_entry(
title=self._title,
data={},
)
async def async_step_confirm(self, user_input=None):
"""Confirm setup."""
if user_input is not None:
return self.async_create_entry(
title=self._title,
data={},
)
return self.async_show_form(
step_id='confirm',
)
async def async_step_discovery(self, discovery_info):
"""Handle a flow initialized by discovery."""
if self._async_in_progress() or self._async_current_entries():
return self.async_abort(
reason='single_instance_allowed'
)
return await self.async_step_confirm()
@callback
def _async_current_entries(self):
"""Return current entries."""
return self.hass.config_entries.async_entries(self._domain)
@callback
def _async_in_progress(self):
"""Return other in progress flows for current domain."""
return [flw for flw in self.hass.config_entries.flow.async_progress()
if flw['handler'] == self._domain and
flw['flow_id'] != self.flow_id]

View File

@ -54,7 +54,7 @@ PyXiaomiGateway==0.9.5
# homeassistant.components.remember_the_milk
RtmAPI==0.7.0
# homeassistant.components.media_player.sonos
# homeassistant.components.sonos
SoCo==0.14
# homeassistant.components.sensor.travisci
@ -773,7 +773,7 @@ pyblackbird==0.5
# homeassistant.components.media_player.channels
pychannels==1.0.0
# homeassistant.components.media_player.cast
# homeassistant.components.cast
pychromecast==2.1.0
# homeassistant.components.media_player.cmus

View File

@ -24,7 +24,7 @@ HAP-python==2.2.2
# homeassistant.components.notify.html5
PyJWT==1.6.0
# homeassistant.components.media_player.sonos
# homeassistant.components.sonos
SoCo==0.14
# homeassistant.components.device_tracker.automatic

View File

@ -0,0 +1 @@
"""Tests for the Cast component."""

View File

@ -0,0 +1,22 @@
"""Tests for the Cast config flow."""
from unittest.mock import patch
from homeassistant import data_entry_flow
from homeassistant.components import cast
from tests.common import MockDependency, mock_coro
async def test_creating_entry_sets_up_media_player(hass):
"""Test setting up Cast loads the media player."""
with patch('homeassistant.components.media_player.cast.async_setup_entry',
return_value=mock_coro(True)) as mock_setup, \
MockDependency('pychromecast', 'discovery'), \
patch('pychromecast.discovery.discover_chromecasts',
return_value=True):
result = await hass.config_entries.flow.async_init(cast.DOMAIN)
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1

View File

@ -0,0 +1 @@
"""Tests for the Sonos component."""

View File

@ -0,0 +1,20 @@
"""Tests for the Sonos config flow."""
from unittest.mock import patch
from homeassistant import data_entry_flow
from homeassistant.components import sonos
from tests.common import mock_coro
async def test_creating_entry_sets_up_media_player(hass):
"""Test setting up Sonos loads the media player."""
with patch('homeassistant.components.media_player.sonos.async_setup_entry',
return_value=mock_coro(True)) as mock_setup, \
patch('soco.discover', return_value=True):
result = await hass.config_entries.flow.async_init(sonos.DOMAIN)
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1

View File

@ -0,0 +1,116 @@
"""Tests for the Config Entry Flow helper."""
from unittest.mock import patch
import pytest
from homeassistant import config_entries, data_entry_flow, loader
from homeassistant.helpers import config_entry_flow
from tests.common import MockConfigEntry, MockModule
@pytest.fixture
def flow_conf(hass):
"""Register a handler."""
handler_conf = {
'discovered': False,
}
async def has_discovered_devices(hass):
"""Mock if we have discovered devices."""
return handler_conf['discovered']
with patch.dict(config_entries.HANDLERS):
config_entry_flow.register_discovery_flow(
'test', 'Test', has_discovered_devices)
yield handler_conf
async def test_single_entry_allowed(hass, flow_conf):
"""Test only a single entry is allowed."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
MockConfigEntry(domain='test').add_to_hass(hass)
result = await flow.async_step_init()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'single_instance_allowed'
async def test_user_no_devices_found(hass, flow_conf):
"""Test if no devices found."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
result = await flow.async_step_init()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'no_devices_found'
async def test_user_no_confirmation(hass, flow_conf):
"""Test user requires no confirmation to setup."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
flow_conf['discovered'] = True
result = await flow.async_step_init()
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
async def test_discovery_single_instance(hass, flow_conf):
"""Test we ask for confirmation via discovery."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
MockConfigEntry(domain='test').add_to_hass(hass)
result = await flow.async_step_discovery({})
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'single_instance_allowed'
async def test_discovery_confirmation(hass, flow_conf):
"""Test we ask for confirmation via discovery."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
result = await flow.async_step_discovery({})
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'confirm'
result = await flow.async_step_confirm({})
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
async def test_multiple_discoveries(hass, flow_conf):
"""Test we only create one instance for multiple discoveries."""
loader.set_component(hass, 'test', MockModule('test'))
result = await hass.config_entries.flow.async_init(
'test', source=data_entry_flow.SOURCE_DISCOVERY, data={})
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
# Second discovery
result = await hass.config_entries.flow.async_init(
'test', source=data_entry_flow.SOURCE_DISCOVERY, data={})
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
async def test_user_init_trumps_discovery(hass, flow_conf):
"""Test a user initialized one will finish and cancel discovered one."""
loader.set_component(hass, 'test', MockModule('test'))
# Discovery starts flow
result = await hass.config_entries.flow.async_init(
'test', source=data_entry_flow.SOURCE_DISCOVERY, data={})
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
# User starts flow
result = await hass.config_entries.flow.async_init('test', data={})
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
# Discovery flow has been aborted
assert len(hass.config_entries.flow.async_progress()) == 0