diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 53f5e704019..bc2f52139e2 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -4,7 +4,7 @@ from homeassistant.helpers import config_entry_flow DOMAIN = 'cast' -REQUIREMENTS = ['pychromecast==2.1.0'] +REQUIREMENTS = ['pychromecast==2.5.0'] async def async_setup(hass, config): diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index b80a8ce5e0f..a58bab92e9d 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -57,6 +57,10 @@ ADDED_CAST_DEVICES_KEY = 'cast_added_cast_devices' # Chromecast or receive it through configuration SIGNAL_CAST_DISCOVERED = 'cast_discovered' +# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is +# removed +SIGNAL_CAST_REMOVED = 'cast_removed' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_IGNORE_CEC, default=[]): @@ -73,6 +77,7 @@ class ChromecastInfo: host = attr.ib(type=str) port = attr.ib(type=int) + service = attr.ib(type=Optional[str], default=None) uuid = attr.ib(type=Optional[str], converter=attr.converters.optional(str), default=None) # always convert UUID to string if not None manufacturer = attr.ib(type=str, default='') @@ -105,13 +110,15 @@ def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo: # Fill out missing information via HTTP dial. from pychromecast import dial - http_device_status = dial.get_device_status(info.host) + http_device_status = dial.get_device_status( + info.host, services=[info.service], + zconf=ChromeCastZeroconf.get_zeroconf()) if http_device_status is None: # HTTP dial didn't give us any new information. return info return ChromecastInfo( - host=info.host, port=info.port, + service=info.service, host=info.host, port=info.port, uuid=(info.uuid or http_device_status.uuid), friendly_name=(info.friendly_name or http_device_status.friendly_name), manufacturer=(info.manufacturer or http_device_status.manufacturer), @@ -122,7 +129,6 @@ def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo: def _discover_chromecast(hass: HomeAssistantType, info: ChromecastInfo): if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]: _LOGGER.debug("Discovered previous chromecast %s", info) - return # Either discovered completely new chromecast or a "moved" one. info = _fill_out_missing_chromecast_info(info) @@ -138,6 +144,29 @@ def _discover_chromecast(hass: HomeAssistantType, info: ChromecastInfo): dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info) +def _remove_chromecast(hass: HomeAssistantType, info: ChromecastInfo): + # Removed chromecast + _LOGGER.debug("Removed chromecast %s", info) + + dispatcher_send(hass, SIGNAL_CAST_REMOVED, info) + + +class ChromeCastZeroconf: + """Class to hold a zeroconf instance.""" + + __zconf = None + + @classmethod + def set_zeroconf(cls, zconf): + """Set zeroconf.""" + cls.__zconf = zconf + + @classmethod + def get_zeroconf(cls): + """Get zeroconf.""" + return cls.__zconf + + def _setup_internal_discovery(hass: HomeAssistantType) -> None: """Set up the pychromecast internal discovery.""" if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data: @@ -149,10 +178,22 @@ def _setup_internal_discovery(hass: HomeAssistantType) -> None: import pychromecast - def internal_callback(name): + def internal_add_callback(name): """Handle zeroconf discovery of a new chromecast.""" mdns = listener.services[name] _discover_chromecast(hass, ChromecastInfo( + service=name, + host=mdns[0], + port=mdns[1], + uuid=mdns[2], + model_name=mdns[3], + friendly_name=mdns[4], + )) + + def internal_remove_callback(name, mdns): + """Handle zeroconf discovery of a removed chromecast.""" + _remove_chromecast(hass, ChromecastInfo( + service=name, host=mdns[0], port=mdns[1], uuid=mdns[2], @@ -161,7 +202,9 @@ def _setup_internal_discovery(hass: HomeAssistantType) -> None: )) _LOGGER.debug("Starting internal pychromecast discovery.") - listener, browser = pychromecast.start_discovery(internal_callback) + listener, browser = pychromecast.start_discovery(internal_add_callback, + internal_remove_callback) + ChromeCastZeroconf.set_zeroconf(browser.zc) def stop_discovery(event): """Stop discovery of new chromecasts.""" @@ -327,12 +370,18 @@ class CastDevice(MediaPlayerDevice): """Initialize the cast device.""" import pychromecast # noqa: pylint: disable=unused-import self._cast_info = cast_info # type: ChromecastInfo + self.services = None + if cast_info.service: + self.services = set() + self.services.add(cast_info.service) self._chromecast = None # type: Optional[pychromecast.Chromecast] self.cast_status = None self.media_status = None self.media_status_received = None self._available = False # type: bool self._status_listener = None # type: Optional[CastStatusListener] + self._add_remove_handler = None + self._del_remove_handler = None async def async_added_to_hass(self): """Create chromecast object when added to hass.""" @@ -345,15 +394,36 @@ class CastDevice(MediaPlayerDevice): if self._cast_info.uuid != discover.uuid: # Discovered is not our device. return + if self.services is None: + _LOGGER.warning( + "[%s %s (%s:%s)] Received update for manually added Cast", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port) + return _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) self.hass.async_create_task(self.async_set_cast_info(discover)) + def async_cast_removed(discover: ChromecastInfo): + """Handle removal of Chromecast.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + return + if self._cast_info.uuid != discover.uuid: + # Removed is not our device. + return + _LOGGER.debug("Removed chromecast with same UUID: %s", discover) + self.hass.async_create_task(self.async_del_cast_info(discover)) + async def async_stop(event): """Disconnect socket on Home Assistant stop.""" await self._async_disconnect() - async_dispatcher_connect(self.hass, SIGNAL_CAST_DISCOVERED, - async_cast_discovered) + self._add_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_CAST_DISCOVERED, + async_cast_discovered) + self._del_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_CAST_REMOVED, + async_cast_removed) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) self.hass.async_create_task(self.async_set_cast_info(self._cast_info)) @@ -364,27 +434,52 @@ class CastDevice(MediaPlayerDevice): # Remove the entity from the added casts so that it can dynamically # be re-added again. self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid) + if self._add_remove_handler: + self._add_remove_handler() + if self._del_remove_handler: + self._del_remove_handler() async def async_set_cast_info(self, cast_info): """Set the cast information and set up the chromecast object.""" import pychromecast - old_cast_info = self._cast_info self._cast_info = cast_info + if self.services is not None: + if cast_info.service not in self.services: + _LOGGER.debug("[%s %s (%s:%s)] Got new service: %s (%s)", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + cast_info.service, self.services) + + self.services.add(cast_info.service) + if self._chromecast is not None: - if old_cast_info.host_port == cast_info.host_port: - _LOGGER.debug("No connection related update: %s", - cast_info.host_port) - return - await self._async_disconnect() + # Only setup the chromecast once, added elements to services + # will automatically be picked up. + return # pylint: disable=protected-access - _LOGGER.debug("Connecting to cast device %s", cast_info) - chromecast = await self.hass.async_add_job( - pychromecast._get_chromecast_from_host, ( - cast_info.host, cast_info.port, cast_info.uuid, - cast_info.model_name, cast_info.friendly_name - )) + if self.services is None: + _LOGGER.debug( + "[%s %s (%s:%s)] Connecting to cast device by host %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, cast_info) + chromecast = await self.hass.async_add_job( + pychromecast._get_chromecast_from_host, ( + cast_info.host, cast_info.port, cast_info.uuid, + cast_info.model_name, cast_info.friendly_name + )) + else: + _LOGGER.debug( + "[%s %s (%s:%s)] Connecting to cast device by service %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, self.services) + chromecast = await self.hass.async_add_job( + pychromecast._get_chromecast_from_service, ( + self.services, ChromeCastZeroconf.get_zeroconf(), + cast_info.uuid, cast_info.model_name, + cast_info.friendly_name + )) self._chromecast = chromecast self._status_listener = CastStatusListener(self, chromecast) # Initialise connection status as connected because we can only @@ -394,15 +489,27 @@ class CastDevice(MediaPlayerDevice): self._available = True self.cast_status = chromecast.status self.media_status = chromecast.media_controller.status - _LOGGER.debug("Connection successful!") + _LOGGER.debug("[%s %s (%s:%s)] Connection successful!", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port) self.async_schedule_update_ha_state() + async def async_del_cast_info(self, cast_info): + """Remove the service.""" + self.services.discard(cast_info.service) + _LOGGER.debug("[%s %s (%s:%s)] Remove service: %s (%s)", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + cast_info.service, self.services) + async def _async_disconnect(self): """Disconnect Chromecast object if it is set.""" if self._chromecast is None: # Can't disconnect if not connected. return - _LOGGER.debug("Disconnecting from chromecast socket.") + _LOGGER.debug("[%s %s (%s:%s)] Disconnecting from chromecast socket.", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port) self._available = False self.async_schedule_update_ha_state() @@ -439,8 +546,11 @@ class CastDevice(MediaPlayerDevice): from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, \ CONNECTION_STATUS_DISCONNECTED - _LOGGER.debug("Received cast device connection status: %s", - connection_status.status) + _LOGGER.debug( + "[%s %s (%s:%s)] Received cast device connection status: %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + connection_status.status) if connection_status.status == CONNECTION_STATUS_DISCONNECTED: self._available = False self._invalidate() @@ -452,8 +562,11 @@ class CastDevice(MediaPlayerDevice): # Connection status callbacks happen often when disconnected. # Only update state when availability changed to put less pressure # on state machine. - _LOGGER.debug("Cast device availability changed: %s", - connection_status.status) + _LOGGER.debug( + "[%s %s (%s:%s)] Cast device availability changed: %s", + self.entity_id, self._cast_info.friendly_name, + self._cast_info.host, self._cast_info.port, + connection_status.status) self._available = new_available self.schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index ea8caac47a8..ba09538df88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -956,7 +956,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==2.1.0 +pychromecast==2.5.0 # homeassistant.components.media_player.cmus pycmus==0.1.1 diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 2e0fe9d1529..b5d6220904f 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -12,8 +12,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.typing import HomeAssistantType from homeassistant.components.cast.media_player import ChromecastInfo from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ - async_dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.cast import media_player as cast from homeassistant.setup import async_setup_component @@ -44,7 +43,7 @@ def get_fake_chromecast_info(host='192.168.178.42', port=8009, uuid: Optional[UUID] = FakeUUID): """Generate a Fake ChromecastInfo with the specified arguments.""" return ChromecastInfo(host=host, port=port, uuid=uuid, - friendly_name="Speaker") + friendly_name="Speaker", service='the-service') async def async_setup_cast(hass, config=None, discovery_info=None): @@ -64,9 +63,10 @@ async def async_setup_cast_internal_discovery(hass, config=None, discovery_info=None): """Set up the cast platform and the discovery.""" listener = MagicMock(services={}) + browser = MagicMock(zc={}) with patch('pychromecast.start_discovery', - return_value=(listener, None)) as start_discovery: + return_value=(listener, browser)) as start_discovery: add_entities = await async_setup_cast(hass, config, discovery_info) await hass.async_block_till_done() await hass.async_block_till_done() @@ -120,8 +120,10 @@ def test_start_discovery_called_once(hass): @asyncio.coroutine def test_stop_discovery_called_on_stop(hass): """Test pychromecast.stop_discovery called on shutdown.""" + browser = MagicMock(zc={}) + with patch('pychromecast.start_discovery', - return_value=(None, 'the-browser')) as start_discovery: + return_value=(None, browser)) as start_discovery: # start_discovery should be called with empty config yield from async_setup_cast(hass, {}) @@ -132,38 +134,16 @@ def test_stop_discovery_called_on_stop(hass): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) yield from hass.async_block_till_done() - stop_discovery.assert_called_once_with('the-browser') + stop_discovery.assert_called_once_with(browser) with patch('pychromecast.start_discovery', - return_value=(None, 'the-browser')) as start_discovery: + return_value=(None, browser)) as start_discovery: # start_discovery should be called again on re-startup yield from async_setup_cast(hass) assert start_discovery.call_count == 1 -async def test_internal_discovery_callback_only_generates_once(hass): - """Test discovery only called once per device.""" - discover_cast, _ = await async_setup_cast_internal_discovery(hass) - info = get_fake_chromecast_info() - - signal = MagicMock() - async_dispatcher_connect(hass, 'cast_discovered', signal) - - with patch('pychromecast.dial.get_device_status', return_value=None): - # discovering a cast device should call the dispatcher - discover_cast('the-service', info) - await hass.async_block_till_done() - discover = signal.mock_calls[0][1][0] - assert discover == info - signal.reset_mock() - - # discovering it a second time shouldn't - discover_cast('the-service', info) - await hass.async_block_till_done() - assert signal.call_count == 0 - - async def test_internal_discovery_callback_fill_out(hass): """Test internal discovery automatically filling out information.""" import pychromecast # imports mock pychromecast @@ -323,35 +303,6 @@ async def test_entity_media_states(hass: HomeAssistantType): assert state.state == 'unknown' -async def test_switched_host(hass: HomeAssistantType): - """Test cast device listens for changed hosts and disconnects old cast.""" - info = get_fake_chromecast_info() - full_info = attr.evolve(info, model_name='google home', - friendly_name='Speaker', uuid=FakeUUID) - - with patch('pychromecast.dial.get_device_status', - return_value=full_info): - chromecast, _ = await async_setup_media_player_cast(hass, full_info) - - chromecast2 = get_fake_chromecast(info) - with patch('pychromecast._get_chromecast_from_host', - return_value=chromecast2) as get_chromecast: - async_dispatcher_send(hass, cast.SIGNAL_CAST_DISCOVERED, full_info) - await hass.async_block_till_done() - assert get_chromecast.call_count == 0 - - changed = attr.evolve(full_info, friendly_name='Speaker 2') - async_dispatcher_send(hass, cast.SIGNAL_CAST_DISCOVERED, changed) - await hass.async_block_till_done() - assert get_chromecast.call_count == 0 - - changed = attr.evolve(changed, host='host2') - async_dispatcher_send(hass, cast.SIGNAL_CAST_DISCOVERED, changed) - await hass.async_block_till_done() - assert get_chromecast.call_count == 1 - assert chromecast.disconnect.call_count == 1 - - async def test_disconnect_on_stop(hass: HomeAssistantType): """Test cast device disconnects socket on stop.""" info = get_fake_chromecast_info()