Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update IDs after firmware upgrade in HEOS #23641

Merged
merged 2 commits into from May 6, 2019
Merged
Changes from all commits
Commits
File filter...
Filter file types
Jump to…
Jump to file or symbol
Failed to load files and symbols.

Always

Just for now

@@ -2,6 +2,7 @@
import asyncio
from datetime import timedelta
import logging
from typing import Dict

import voluptuous as vol

@@ -16,8 +17,8 @@

from .config_flow import format_title
from .const import (
COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER,
DATA_SOURCE_MANAGER, DOMAIN, SIGNAL_HEOS_SOURCES_UPDATED)
COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER_MANAGER,
DATA_SOURCE_MANAGER, DOMAIN, SIGNAL_HEOS_UPDATED)

CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
@@ -89,11 +90,14 @@
exc_info=isinstance(error, CommandError))
raise ConfigEntryNotReady

controller_manager = ControllerManager(hass, controller)
await controller_manager.connect_listeners()

source_manager = SourceManager(favorites, inputs)
source_manager.connect_update(hass, controller)

hass.data[DOMAIN] = {
DATA_CONTROLLER: controller,
DATA_CONTROLLER_MANAGER: controller_manager,
DATA_SOURCE_MANAGER: source_manager,
MEDIA_PLAYER_DOMAIN: players
}
@@ -104,14 +108,91 @@

async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
"""Unload a config entry."""
controller = hass.data[DOMAIN][DATA_CONTROLLER]
controller.dispatcher.disconnect_all()
await controller.disconnect()
controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER]
await controller_manager.disconnect()
hass.data.pop(DOMAIN)
return await hass.config_entries.async_forward_entry_unload(
entry, MEDIA_PLAYER_DOMAIN)


class ControllerManager:
"""Class that manages events of the controller."""

def __init__(self, hass, controller):
"""Init the controller manager."""
self._hass = hass
self._device_registry = None
self._entity_registry = None
self.controller = controller
self._signals = []

async def connect_listeners(self):
"""Subscribe to events of interest."""
from pyheos import const
self._device_registry, self._entity_registry = await asyncio.gather(
self._hass.helpers.device_registry.async_get_registry(),
self._hass.helpers.entity_registry.async_get_registry())
# Handle controller events
self._signals.append(self.controller.dispatcher.connect(
const.SIGNAL_CONTROLLER_EVENT, self._controller_event))
# Handle connection-related events
self._signals.append(self.controller.dispatcher.connect(
const.SIGNAL_HEOS_EVENT, self._heos_event))

async def disconnect(self):
"""Disconnect subscriptions."""
for signal_remove in self._signals:
signal_remove()
self._signals.clear()
self.controller.dispatcher.disconnect_all()
await self.controller.disconnect()

async def _controller_event(self, event, data):
"""Handle controller event."""
from pyheos import const
if event == const.EVENT_PLAYERS_CHANGED:
self.update_ids(data[const.DATA_MAPPED_IDS])
# Update players
self._hass.helpers.dispatcher.async_dispatcher_send(
SIGNAL_HEOS_UPDATED)

async def _heos_event(self, event):
"""Handle connection event."""
from pyheos import CommandError, const
if event == const.EVENT_CONNECTED:
try:
# Retrieve latest players and refresh status
data = await self.controller.load_players()
self.update_ids(data[const.DATA_MAPPED_IDS])
except (CommandError, asyncio.TimeoutError, ConnectionError) as ex:
_LOGGER.error("Unable to refresh players: %s", ex)
# Update players
self._hass.helpers.dispatcher.async_dispatcher_send(
SIGNAL_HEOS_UPDATED)

def update_ids(self, mapped_ids: Dict[int, int]):
"""Update the IDs in the device and entity registry."""
# mapped_ids contains the mapped IDs (new:old)
for new_id, old_id in mapped_ids.items():
# update device registry
entry = self._device_registry.async_get_device(
{(DOMAIN, old_id)}, set())
new_identifiers = {(DOMAIN, new_id)}
if entry:
self._device_registry.async_update_device(
entry.id, new_identifiers=new_identifiers)
_LOGGER.debug("Updated device %s identifiers to %s",
entry.id, new_identifiers)
# update entity registry
entity_id = self._entity_registry.async_get_entity_id(
MEDIA_PLAYER_DOMAIN, DOMAIN, str(old_id))
if entity_id:
self._entity_registry.async_update_entity(
entity_id, new_unique_id=str(new_id))
_LOGGER.debug("Updated entity %s unique id to %s",
entity_id, new_id)


class SourceManager:
"""Class that manages sources for players."""

@@ -195,9 +276,10 @@ def connect_update(self, hass, controller):
exc_info=isinstance(error, CommandError))
return

async def update_sources(event, data):
async def update_sources(event, data=None):
if event in (const.EVENT_SOURCES_CHANGED,
const.EVENT_USER_CHANGED):
const.EVENT_USER_CHANGED,
const.EVENT_CONNECTED):
sources = await get_sources()
# If throttled, it will return None
if sources:
@@ -206,7 +288,9 @@ def connect_update(self, hass, controller):
_LOGGER.debug("Sources updated due to changed event")
# Let players know to update
hass.helpers.dispatcher.async_dispatcher_send(
SIGNAL_HEOS_SOURCES_UPDATED)
SIGNAL_HEOS_UPDATED)

controller.dispatcher.connect(
const.SIGNAL_CONTROLLER_EVENT, update_sources)
controller.dispatcher.connect(
const.SIGNAL_HEOS_EVENT, update_sources)
@@ -2,8 +2,8 @@

COMMAND_RETRY_ATTEMPTS = 2
COMMAND_RETRY_DELAY = 1
DATA_CONTROLLER = "controller"
DATA_CONTROLLER_MANAGER = "controller"
DATA_SOURCE_MANAGER = "source_manager"
DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
DOMAIN = 'heos'
SIGNAL_HEOS_SOURCES_UPDATED = "heos_sources_updated"
SIGNAL_HEOS_UPDATED = "heos_updated"
@@ -18,7 +18,7 @@
from homeassistant.util.dt import utcnow

from .const import (
DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_SOURCES_UPDATED)
DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_UPDATED)

BASE_SUPPORTED_FEATURES = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
SUPPORT_VOLUME_STEP | SUPPORT_CLEAR_PLAYLIST | \
@@ -81,23 +81,6 @@ def __init__(self, player):
const.CONTROL_PLAY_NEXT: SUPPORT_NEXT_TRACK
}

async def _controller_event(self, event, data):
"""Handle controller event."""
from pyheos import const
if event == const.EVENT_PLAYERS_CHANGED:
await self.async_update_ha_state(True)

async def _heos_event(self, event):
"""Handle connection event."""
from pyheos import CommandError, const
if event == const.EVENT_CONNECTED:
try:
await self._player.refresh()
except (CommandError, asyncio.TimeoutError, ConnectionError) as ex:
_LOGGER.error("Unable to refresh player %s: %s",
self._player, ex)
await self.async_update_ha_state(True)

async def _player_update(self, player_id, event):
"""Handle player attribute updated."""
from pyheos import const
@@ -107,7 +90,7 @@ def __init__(self, player):
self._media_position_updated_at = utcnow()
await self.async_update_ha_state(True)

async def _sources_updated(self):
async def _heos_updated(self):
"""Handle sources changed."""
await self.async_update_ha_state(True)

@@ -118,16 +101,10 @@ def __init__(self, player):
# Update state when attributes of the player change
self._signals.append(self._player.heos.dispatcher.connect(
const.SIGNAL_PLAYER_EVENT, self._player_update))
# Update state when available players change
self._signals.append(self._player.heos.dispatcher.connect(
const.SIGNAL_CONTROLLER_EVENT, self._controller_event))
# Update state upon connect/disconnects
self._signals.append(self._player.heos.dispatcher.connect(
const.SIGNAL_HEOS_EVENT, self._heos_event))
# Update state when sources change
# Update state when heos changes
self._signals.append(
self.hass.helpers.dispatcher.async_dispatcher_connect(
SIGNAL_HEOS_SOURCES_UPDATED, self._sources_updated))
SIGNAL_HEOS_UPDATED, self._heos_updated))

@log_command_error("clear playlist")
async def async_clear_playlist(self):
@@ -252,7 +229,7 @@ def device_info(self) -> dict:
"""Get attributes about the device."""
return {
'identifiers': {
(DOMAIN, self._player.player_id)
(HEOS_DOMAIN, self._player.player_id)
},
'name': self._player.name,
'model': self._player.model,
@@ -20,7 +20,7 @@ def config_entry_fixture():

@pytest.fixture(name="controller")
def controller_fixture(
players, favorites, input_sources, playlists, dispatcher):
players, favorites, input_sources, playlists, change_data, dispatcher):
"""Create a mock Heos controller fixture."""
with patch("pyheos.Heos", autospec=True) as mock:
mock_heos = mock.return_value
@@ -32,6 +32,7 @@ def controller_fixture(
mock_heos.get_favorites.return_value = favorites
mock_heos.get_input_sources.return_value = input_sources
mock_heos.get_playlists.return_value = playlists
mock_heos.load_players.return_value = change_data
mock_heos.is_signed_in = True
mock_heos.signed_in_username = "user@user.com"
yield mock_heos
@@ -149,3 +150,23 @@ def playlists_fixture() -> Sequence[HeosSource]:
playlist.type = const.TYPE_PLAYLIST
playlist.name = "Awesome Music"
return [playlist]


@pytest.fixture(name="change_data")
def change_data_fixture() -> Dict:
"""Create player change data for testing."""
return {
const.DATA_MAPPED_IDS: {},
const.DATA_NEW: []
}


@pytest.fixture(name="change_data_mapped_ids")
def change_data_mapped_ids_fixture() -> Dict:
"""Create player change data for testing."""
return {
const.DATA_MAPPED_IDS: {
101: 1
},
const.DATA_NEW: []
}
@@ -1,13 +1,14 @@
"""Tests for the init module."""
import asyncio

from asynctest import patch
from asynctest import Mock, patch
from pyheos import CommandError, const
import pytest

from homeassistant.components.heos import async_setup_entry, async_unload_entry
from homeassistant.components.heos import (
ControllerManager, async_setup_entry, async_unload_entry)
from homeassistant.components.heos.const import (
DATA_CONTROLLER, DATA_SOURCE_MANAGER, DOMAIN)
DATA_CONTROLLER_MANAGER, DATA_SOURCE_MANAGER, DOMAIN)
from homeassistant.components.media_player.const import (
DOMAIN as MEDIA_PLAYER_DOMAIN)
from homeassistant.const import CONF_HOST
@@ -74,7 +75,7 @@
assert controller.get_favorites.call_count == 1
assert controller.get_input_sources.call_count == 1
controller.disconnect.assert_not_called()
assert hass.data[DOMAIN][DATA_CONTROLLER] == controller
assert hass.data[DOMAIN][DATA_CONTROLLER_MANAGER].controller == controller
assert hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN] == controller.players
assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].favorites == favorites
assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].inputs == input_sources
@@ -97,7 +98,7 @@
assert controller.get_favorites.call_count == 0
assert controller.get_input_sources.call_count == 1
controller.disconnect.assert_not_called()
assert hass.data[DOMAIN][DATA_CONTROLLER] == controller
assert hass.data[DOMAIN][DATA_CONTROLLER_MANAGER].controller == controller
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
@@ -139,12 +140,13 @@

async def test_unload_entry(hass, config_entry, controller):
"""Test entries are unloaded correctly."""
hass.data[DOMAIN] = {DATA_CONTROLLER: controller}
controller_manager = Mock(ControllerManager)
hass.data[DOMAIN] = {DATA_CONTROLLER_MANAGER: controller_manager}
with patch.object(hass.config_entries, 'async_forward_entry_unload',
return_value=True) as unload:
assert await async_unload_entry(hass, config_entry)
await hass.async_block_till_done()
assert controller.disconnect.call_count == 1
assert controller_manager.disconnect.call_count == 1
assert unload.call_count == 1
assert DOMAIN not in hass.data

ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.