Use initial received WebSocket state in Bang & Olufsen (#152432)

This commit is contained in:
Markus Jacobsen
2025-09-30 13:34:43 +02:00
committed by GitHub
parent 474b40511f
commit 0960d78eb5
8 changed files with 109 additions and 66 deletions

View File

@@ -73,11 +73,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
# Add the websocket and API client
entry.runtime_data = BangOlufsenData(websocket, client)
# Start WebSocket connection
await client.connect_notifications(remote_control=True, reconnect=True)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Start WebSocket connection once the platforms have been loaded.
# This ensures that the initial WebSocket notifications are dispatched to entities
await client.connect_notifications(remote_control=True, reconnect=True)
return True

View File

@@ -125,7 +125,8 @@ async def async_setup_entry(
async_add_entities(
new_entities=[
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
]
],
update_before_add=True,
)
# Register actions.
@@ -266,34 +267,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._software_status.software_version,
)
# Get overall device state once. This is handled by WebSocket events the rest of the time.
product_state = await self._client.get_product_state()
# Get volume information.
if product_state.volume:
self._volume = product_state.volume
# Get all playback information.
# Ensure that the metadata is not None upon startup
if product_state.playback:
if product_state.playback.metadata:
self._playback_metadata = product_state.playback.metadata
self._remote_leader = product_state.playback.metadata.remote_leader
if product_state.playback.progress:
self._playback_progress = product_state.playback.progress
if product_state.playback.source:
self._source_change = product_state.playback.source
if product_state.playback.state:
self._playback_state = product_state.playback.state
# Set initial state
if self._playback_state.value:
self._state = self._playback_state.value
self._attr_media_position_updated_at = utcnow()
# Get the highest resolution available of the given images.
self._media_image = get_highest_resolution_artwork(self._playback_metadata)
# If the device has been updated with new sources, then the API will fail here.
await self._async_update_sources()

View File

@@ -76,6 +76,39 @@ def mock_config_entry_core() -> MockConfigEntry:
)
async def mock_websocket_connection(
hass: HomeAssistant, mock_mozart_client: AsyncMock
) -> None:
"""Register and receive initial WebSocket notifications."""
# Currently only add notifications that are used.
# Register callbacks.
volume_callback = mock_mozart_client.get_volume_notifications.call_args[0][0]
source_change_callback = (
mock_mozart_client.get_source_change_notifications.call_args[0][0]
)
playback_state_callback = (
mock_mozart_client.get_playback_state_notifications.call_args[0][0]
)
playback_metadata_callback = (
mock_mozart_client.get_playback_metadata_notifications.call_args[0][0]
)
# Trigger callbacks. Try to use existing data
volume_callback(mock_mozart_client.get_product_state.return_value.volume)
source_change_callback(
mock_mozart_client.get_product_state.return_value.playback.source
)
playback_state_callback(
mock_mozart_client.get_product_state.return_value.playback.state
)
playback_metadata_callback(
mock_mozart_client.get_product_state.return_value.playback.metadata
)
await hass.async_block_till_done()
@pytest.fixture(name="integration")
async def integration_fixture(
hass: HomeAssistant,
@@ -88,6 +121,8 @@ async def integration_fixture(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await mock_websocket_connection(hass, mock_mozart_client)
@pytest.fixture
def mock_mozart_client() -> Generator[AsyncMock]:

View File

@@ -64,6 +64,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': 'music',
'repeat': 'off',
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',

View File

@@ -47,7 +47,7 @@
'state': 'playing',
})
# ---
# name: test_async_beolink_expand[all_discovered-True-None-log_messages0-2]
# name: test_async_beolink_expand[all_discovered-True-None-log_messages0-3]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -96,7 +96,7 @@
'state': 'playing',
})
# ---
# name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-2]
# name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-3]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -145,7 +145,7 @@
'state': 'playing',
})
# ---
# name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-1]
# name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-2]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -194,7 +194,7 @@
'state': 'playing',
})
# ---
# name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-1]
# name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-2]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -412,6 +412,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -458,6 +460,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -504,6 +508,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -647,6 +653,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -659,13 +667,14 @@
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
'volume_level': 0.0,
}),
'context': <ANY>,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
'state': 'idle',
})
# ---
# name: test_async_join_players[group_members1-0-1]
@@ -742,6 +751,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -754,13 +765,14 @@
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
'volume_level': 0.0,
}),
'context': <ANY>,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
'state': 'idle',
})
# ---
# name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source]
@@ -789,6 +801,8 @@
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'media_position': 0,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -836,6 +850,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -848,13 +864,14 @@
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
'volume_level': 0.0,
}),
'context': <ANY>,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
'state': 'idle',
})
# ---
# name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity]
@@ -882,6 +899,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -929,6 +948,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -941,13 +962,14 @@
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
'volume_level': 0.0,
}),
'context': <ANY>,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
'state': 'idle',
})
# ---
# name: test_async_unjoin_player
@@ -1021,6 +1043,8 @@
'media_player.beosound_balance_11111111',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -1067,6 +1091,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -1079,12 +1105,13 @@
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
'volume_level': 0.0,
}),
'context': <ANY>,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
'state': 'idle',
})
# ---

View File

@@ -6,9 +6,10 @@ from syrupy.filters import props
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
from .conftest import mock_websocket_connection
from .const import TEST_BUTTON_EVENT_ENTITY_ID
from tests.common import MockConfigEntry
from tests.common import AsyncMock, MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@@ -19,6 +20,7 @@ async def test_async_get_config_entry_diagnostics(
hass_client: ClientSessionGenerator,
integration: None,
mock_config_entry: MockConfigEntry,
mock_mozart_client: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test config entry diagnostics."""
@@ -27,6 +29,9 @@ async def test_async_get_config_entry_diagnostics(
entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None)
hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
# Re-trigger WebSocket events after the reload
await mock_websocket_connection(hass, mock_mozart_client)
result = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)

View File

@@ -16,6 +16,7 @@ from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
from .conftest import mock_websocket_connection
from .const import TEST_BUTTON_EVENT_ENTITY_ID
from tests.common import MockConfigEntry
@@ -61,6 +62,7 @@ async def test_button_event_creation_beoconnect_core(
# Load entry
mock_config_entry_core.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_core.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
# Check number of entities
# The media_player entity should be the only available

View File

@@ -76,6 +76,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.setup import async_setup_component
from .conftest import mock_websocket_connection
from .const import (
TEST_ACTIVE_SOUND_MODE_NAME,
TEST_ACTIVE_SOUND_MODE_NAME_2,
@@ -126,12 +127,12 @@ async def test_initialization(
mock_mozart_client: AsyncMock,
) -> None:
"""Test the integration is initialized properly in _initialize, async_added_to_hass and __init__."""
caplog.set_level(logging.DEBUG)
# Setup entity
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
# Ensure that the logger has been called with the debug message
assert "Connected to: Beosound Balance 11111111 running SW 1.0.0" in caplog.text
@@ -145,14 +146,13 @@ async def test_initialization(
# Check API calls
mock_mozart_client.get_softwareupdate_status.assert_called_once()
mock_mozart_client.get_product_state.assert_called_once()
mock_mozart_client.get_available_sources.assert_called_once()
mock_mozart_client.get_remote_menu.assert_called_once()
mock_mozart_client.get_listening_mode_set.assert_called_once()
mock_mozart_client.get_active_listening_mode.assert_called_once()
mock_mozart_client.get_beolink_self.assert_called_once()
mock_mozart_client.get_beolink_peers.assert_called_once()
mock_mozart_client.get_beolink_listeners.assert_called_once()
assert mock_mozart_client.get_beolink_peers.call_count == 2
assert mock_mozart_client.get_beolink_listeners.call_count == 2
async def test_async_update_sources_audio_only(
@@ -165,6 +165,7 @@ async def test_async_update_sources_audio_only(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_AUDIO_SOURCES
@@ -180,6 +181,7 @@ async def test_async_update_sources_outdated_api(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert (
@@ -194,7 +196,6 @@ async def test_async_update_sources_remote(
mock_mozart_client: AsyncMock,
) -> None:
"""Test _async_update_sources is called when there are new video sources."""
notification_callback = mock_mozart_client.get_notification_notifications.call_args[
0
][0]
@@ -221,6 +222,7 @@ async def test_async_update_sources_availability(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
playback_source_callback = (
mock_mozart_client.get_playback_source_notifications.call_args[0][0]
@@ -408,7 +410,6 @@ async def test_async_turn_off(
mock_mozart_client: AsyncMock,
) -> None:
"""Test async_turn_off."""
playback_state_callback = (
mock_mozart_client.get_playback_state_notifications.call_args[0][0]
)
@@ -475,6 +476,7 @@ async def test_async_update_beolink_line_in(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
source_change_callback = (
mock_mozart_client.get_source_change_notifications.call_args[0][0]
@@ -488,9 +490,9 @@ async def test_async_update_beolink_line_in(
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states.attributes["group_members"] == []
# Called once during _initialize and once during _async_update_beolink
assert mock_mozart_client.get_beolink_listeners.call_count == 2
assert mock_mozart_client.get_beolink_peers.call_count == 2
# Called twice during _initialize and once during WebSocket connection
assert mock_mozart_client.get_beolink_listeners.call_count == 3
assert mock_mozart_client.get_beolink_peers.call_count == 3
async def test_async_update_beolink_listener(
@@ -525,10 +527,10 @@ async def test_async_update_beolink_listener(
]
# Called once for each entity during _initialize
assert mock_mozart_client.get_beolink_listeners.call_count == 2
assert mock_mozart_client.get_beolink_listeners.call_count == 3
# Called once for each entity during _initialize and
# once more during _async_update_beolink for the entity that has the callback associated with it.
assert mock_mozart_client.get_beolink_peers.call_count == 3
assert mock_mozart_client.get_beolink_peers.call_count == 4
# Main entity
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
@@ -553,6 +555,7 @@ async def test_async_update_name_and_beolink(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
configuration_callback = (
mock_mozart_client.get_notification_notifications.call_args[0][0]
@@ -563,8 +566,8 @@ async def test_async_update_name_and_beolink(
await hass.async_block_till_done()
assert mock_mozart_client.get_beolink_self.call_count == 2
assert mock_mozart_client.get_beolink_peers.call_count == 2
assert mock_mozart_client.get_beolink_listeners.call_count == 2
assert mock_mozart_client.get_beolink_peers.call_count == 3
assert mock_mozart_client.get_beolink_listeners.call_count == 3
# Check that device name has been changed
assert mock_config_entry.unique_id
@@ -841,7 +844,6 @@ async def test_async_select_sound_mode_invalid(
integration: None,
) -> None:
"""Test async_select_sound_mode with an invalid sound_mode."""
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
@@ -863,7 +865,6 @@ async def test_async_play_media_invalid_type(
integration: None,
) -> None:
"""Test async_play_media only accepts valid media types."""
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
@@ -910,7 +911,6 @@ async def test_async_play_media_overlay_absolute_volume_uri(
mock_mozart_client: AsyncMock,
) -> None:
"""Test async_play_media overlay with Home Assistant local URI and absolute volume."""
await async_setup_component(hass, "media_source", {"media_source": {}})
await hass.services.async_call(
@@ -1062,7 +1062,6 @@ async def test_async_play_media_deezer_flow(
mock_mozart_client: AsyncMock,
) -> None:
"""Test async_play_media with Deezer flow."""
# Send a service call
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
@@ -1132,7 +1131,6 @@ async def test_async_play_media_invalid_deezer(
mock_mozart_client: AsyncMock,
) -> None:
"""Test async_play_media with an invalid/no Deezer login."""
mock_mozart_client.start_deezer_flow.side_effect = TEST_DEEZER_INVALID_FLOW
with pytest.raises(HomeAssistantError) as exc_info:
@@ -1231,7 +1229,6 @@ async def test_async_browse_media(
present: bool,
) -> None:
"""Test async_browse_media with audio and video source."""
await async_setup_component(hass, "media_source", {"media_source": {}})
client = await hass_ws_client()
@@ -1489,18 +1486,18 @@ async def test_async_beolink_join_invalid(
[
# All discovered
# Valid peers
("all_discovered", True, None, [], 2),
("all_discovered", True, None, [], 3),
# Invalid peers
(
"all_discovered",
True,
NotFoundException(),
[f"Unable to expand to {TEST_JID_3}", f"Unable to expand to {TEST_JID_4}"],
2,
3,
),
# Beolink JIDs
# Valid peer
("beolink_jids", [TEST_JID_3, TEST_JID_4], None, [], 1),
("beolink_jids", [TEST_JID_3, TEST_JID_4], None, [], 2),
# Invalid peer
(
"beolink_jids",
@@ -1510,7 +1507,7 @@ async def test_async_beolink_join_invalid(
f"Unable to expand to {TEST_JID_3}. Is the device available on the network?",
f"Unable to expand to {TEST_JID_4}. Is the device available on the network?",
],
1,
2,
),
],
)
@@ -1622,9 +1619,8 @@ async def test_async_set_repeat(
repeat: RepeatMode,
) -> None:
"""Test async_set_repeat."""
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert ATTR_MEDIA_REPEAT not in states.attributes
assert states.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.OFF
# Set the return value of the repeat endpoint to match service call
mock_mozart_client.get_settings_queue.return_value = PlayQueueSettings(
@@ -1668,7 +1664,7 @@ async def test_async_set_shuffle(
) -> None:
"""Test async_set_shuffle."""
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert ATTR_MEDIA_SHUFFLE not in states.attributes
assert states.attributes[ATTR_MEDIA_SHUFFLE] is False
# Set the return value of the shuffle endpoint to match service call
mock_mozart_client.get_settings_queue.return_value = PlayQueueSettings(