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,
HeosNowPlayingMedia,
HeosOptions,
HeosPlayer,
MediaItem,
MediaType,
PlayerUpdateResult,
@ -58,6 +59,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
credentials=credentials,
)
)
self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = []
self._update_sources_pending: bool = False
self._source_list: list[str] = []
self._favorites: dict[int, MediaItem] = {}
@ -124,6 +126,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
self.async_update_listeners()
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:
"""Handle when the user credentials are no longer valid."""
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."""
if event == const.EVENT_PLAYERS_CHANGED:
assert data is not None
if data.updated_player_ids:
self._async_update_player_ids(data.updated_player_ids)
self._async_handle_player_update_result(data)
elif (
event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED)
and not self._update_sources_pending
@ -242,9 +264,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
except HeosError as error:
_LOGGER.error("Unable to refresh players: %s", error)
return
# After reconnecting, player_id may have changed
if player_updates.updated_player_ids:
self._async_update_player_ids(player_updates.updated_player_ids)
self._async_handle_player_update_result(player_updates)
@callback
def async_get_source_list(self) -> list[str]:

View File

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

View File

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

View File

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

View File

@ -1,16 +1,26 @@
"""Tests for the init module."""
from collections.abc import Callable
from typing import cast
from unittest.mock import Mock
from pyheos import HeosError, HeosOptions, SignalHeosEvent, SignalType
from pyheos import (
HeosError,
HeosOptions,
HeosPlayer,
PlayerUpdateResult,
SignalHeosEvent,
SignalType,
const,
)
import pytest
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.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
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 . import MockHeos
@ -255,3 +265,64 @@ async def test_remove_config_entry_device(
ws_client = await hass_ws_client(hass)
response = await ws_client.remove_device(device_entry.id, config_entry.entry_id)
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")