Create HEOS devices after integration setup (#138721)

* Create entities for new players

* Fix docstring typo
This commit is contained in:
Andrew Sayre 2025-02-17 09:28:55 -06:00 committed by GitHub
parent 82f2e72327
commit 34a33e0465
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 147 additions and 39 deletions

View File

@ -16,6 +16,7 @@ from pyheos import (
HeosError, HeosError,
HeosNowPlayingMedia, HeosNowPlayingMedia,
HeosOptions, HeosOptions,
HeosPlayer,
MediaItem, MediaItem,
MediaType, MediaType,
PlayerUpdateResult, PlayerUpdateResult,
@ -58,6 +59,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
credentials=credentials, credentials=credentials,
) )
) )
self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = []
self._update_sources_pending: bool = False self._update_sources_pending: bool = False
self._source_list: list[str] = [] self._source_list: list[str] = []
self._favorites: dict[int, MediaItem] = {} self._favorites: dict[int, MediaItem] = {}
@ -124,6 +126,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
self.async_update_listeners() self.async_update_listeners()
return remove_listener return remove_listener
def async_add_platform_callback(
self, add_entities_callback: Callable[[Sequence[HeosPlayer]], None]
) -> None:
"""Add a callback to add entities for a platform."""
self._platform_callbacks.append(add_entities_callback)
def _async_handle_player_update_result(
self, update_result: PlayerUpdateResult
) -> None:
"""Handle a player update result."""
if update_result.added_player_ids and self._platform_callbacks:
new_players = [
self.heos.players[player_id]
for player_id in update_result.added_player_ids
]
for add_entities_callback in self._platform_callbacks:
add_entities_callback(new_players)
if update_result.updated_player_ids:
self._async_update_player_ids(update_result.updated_player_ids)
async def _async_on_auth_failure(self) -> None: async def _async_on_auth_failure(self) -> None:
"""Handle when the user credentials are no longer valid.""" """Handle when the user credentials are no longer valid."""
assert self.config_entry is not None assert self.config_entry is not None
@ -147,8 +170,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
"""Handle a controller event, such as players or groups changed.""" """Handle a controller event, such as players or groups changed."""
if event == const.EVENT_PLAYERS_CHANGED: if event == const.EVENT_PLAYERS_CHANGED:
assert data is not None assert data is not None
if data.updated_player_ids: self._async_handle_player_update_result(data)
self._async_update_player_ids(data.updated_player_ids)
elif ( elif (
event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED)
and not self._update_sources_pending and not self._update_sources_pending
@ -242,9 +264,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
except HeosError as error: except HeosError as error:
_LOGGER.error("Unable to refresh players: %s", error) _LOGGER.error("Unable to refresh players: %s", error)
return return
# After reconnecting, player_id may have changed self._async_handle_player_update_result(player_updates)
if player_updates.updated_player_ids:
self._async_update_player_ids(player_updates.updated_player_ids)
@callback @callback
def async_get_source_list(self) -> list[str]: def async_get_source_list(self) -> list[str]:

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine, Sequence
from datetime import datetime from datetime import datetime
from functools import reduce, wraps from functools import reduce, wraps
from operator import ior from operator import ior
@ -93,11 +93,16 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Add media players for a config entry.""" """Add media players for a config entry."""
devices = [
HeosMediaPlayer(entry.runtime_data, player) def add_entities_callback(players: Sequence[HeosPlayer]) -> None:
for player in entry.runtime_data.heos.players.values() """Add entities for each player."""
] async_add_entities(
async_add_entities(devices) [HeosMediaPlayer(entry.runtime_data, player) for player in players]
)
coordinator = entry.runtime_data
coordinator.async_add_platform_callback(add_entities_callback)
add_entities_callback(list(coordinator.heos.players.values()))
type _FuncType[**_P] = Callable[_P, Awaitable[Any]] type _FuncType[**_P] = Callable[_P, Awaitable[Any]]

View File

@ -49,7 +49,7 @@ rules:
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: done docs-troubleshooting: done
docs-use-cases: done docs-use-cases: done
dynamic-devices: todo dynamic-devices: done
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator from collections.abc import Callable, Iterator
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from pyheos import ( from pyheos import (
@ -130,16 +130,17 @@ def system_info_fixture() -> HeosSystem:
) )
@pytest.fixture(name="players") @pytest.fixture(name="player_factory")
def players_fixture() -> dict[int, HeosPlayer]: def player_factory_fixture() -> Callable[[int, str, str], HeosPlayer]:
"""Create two mock HeosPlayers.""" """Return a method that creates players."""
players = {}
for i in (1, 2): def factory(player_id: int, name: str, model: str) -> HeosPlayer:
player = HeosPlayer( """Create a player."""
player_id=i, return HeosPlayer(
player_id=player_id,
group_id=999, group_id=999,
name="Test Player" if i == 1 else f"Test Player {i}", name=name,
model="HEOS Drive HS2" if i == 1 else "Speaker", model=model,
serial="123456", serial="123456",
version="1.0.0", version="1.0.0",
supported_version=True, supported_version=True,
@ -147,26 +148,37 @@ def players_fixture() -> dict[int, HeosPlayer]:
is_muted=False, is_muted=False,
available=True, available=True,
state=PlayState.STOP, state=PlayState.STOP,
ip_address=f"127.0.0.{i}", ip_address=f"127.0.0.{player_id}",
network=NetworkType.WIRED, network=NetworkType.WIRED,
shuffle=False, shuffle=False,
repeat=RepeatType.OFF, repeat=RepeatType.OFF,
volume=25, volume=25,
now_playing_media=HeosNowPlayingMedia(
type=MediaType.STATION,
song="Song",
station="Station Name",
album="Album",
artist="Artist",
image_url="http://",
album_id="1",
media_id="1",
queue_id=1,
source_id=10,
),
) )
player.now_playing_media = HeosNowPlayingMedia(
type=MediaType.STATION, return factory
song="Song",
station="Station Name",
album="Album", @pytest.fixture(name="players")
artist="Artist", def players_fixture(
image_url="http://", player_factory: Callable[[int, str, str], HeosPlayer],
album_id="1", ) -> dict[int, HeosPlayer]:
media_id="1", """Create two mock HeosPlayers."""
queue_id=1, return {
source_id=10, 1: player_factory(1, "Test Player", "HEOS Drive HS2"),
) 2: player_factory(2, "Test Player 2", "Speaker"),
players[player.player_id] = player }
return players
@pytest.fixture(name="group") @pytest.fixture(name="group")

View File

@ -1,16 +1,26 @@
"""Tests for the init module.""" """Tests for the init module."""
from collections.abc import Callable
from typing import cast from typing import cast
from unittest.mock import Mock from unittest.mock import Mock
from pyheos import HeosError, HeosOptions, SignalHeosEvent, SignalType from pyheos import (
HeosError,
HeosOptions,
HeosPlayer,
PlayerUpdateResult,
SignalHeosEvent,
SignalType,
const,
)
import pytest import pytest
from homeassistant.components.heos.const import DOMAIN from homeassistant.components.heos.const import DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from . import MockHeos from . import MockHeos
@ -255,3 +265,64 @@ async def test_remove_config_entry_device(
ws_client = await hass_ws_client(hass) ws_client = await hass_ws_client(hass)
response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id)
assert response["success"] == expected_result assert response["success"] == expected_result
async def test_reconnected_new_entities_created(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
config_entry: MockConfigEntry,
controller: MockHeos,
player_factory: Callable[[int, str, str], HeosPlayer],
) -> None:
"""Test new entities are created for new players after reconnecting."""
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
# Assert initial entity doesn't exist
assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3")
# Create player
players = controller.players.copy()
players[3] = player_factory(3, "Test Player 3", "HEOS Link")
controller.mock_set_players(players)
controller.load_players.return_value = PlayerUpdateResult([3], [], {})
# Simulate reconnection
await controller.dispatcher.wait_send(
SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED
)
await hass.async_block_till_done()
# Assert new entity created
assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3")
async def test_players_changed_new_entities_created(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
config_entry: MockConfigEntry,
controller: MockHeos,
player_factory: Callable[[int, str, str], HeosPlayer],
) -> None:
"""Test new entities are created for new players on change event."""
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
# Assert initial entity doesn't exist
assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3")
# Create player
players = controller.players.copy()
players[3] = player_factory(3, "Test Player 3", "HEOS Link")
controller.mock_set_players(players)
# Simulate players changed event
await controller.dispatcher.wait_send(
SignalType.CONTROLLER_EVENT,
const.EVENT_PLAYERS_CHANGED,
PlayerUpdateResult([3], [], {}),
)
await hass.async_block_till_done()
# Assert new entity created
assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3")