Add beolink_join source_id parameter to Bang & Olufsen (#132377)

* Add source as parameter to beolink join service

* Add beolink join source and responses

* Improve comment
Add translation

* Remove result from beolink join custom action

* Cleanup

* Use options selector instead of string for source ID
Fix test docstring

* Update options

* Use translation dict for source ids
Add input validation
Add tests for invalid sources
Improve source id description

* Use list instead of translation dict
Remove platform prefixes
Add test for Beolink Converter source

* Fix source_id naming and order
This commit is contained in:
Markus Jacobsen 2024-12-10 18:01:12 +01:00 committed by GitHub
parent 8fd64d2ca4
commit d4546c94b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 387 additions and 10 deletions

View File

@ -210,3 +210,20 @@ BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
# Beolink Converter NL/ML sources need to be transformed to upper case
BEOLINK_JOIN_SOURCES_TO_UPPER = (
"aux_a",
"cd",
"ph",
"radio",
"tp1",
"tp2",
)
BEOLINK_JOIN_SOURCES = (
*BEOLINK_JOIN_SOURCES_TO_UPPER,
"beoradio",
"deezer",
"spotify",
"tidal",
)

View File

@ -74,6 +74,8 @@ from .const import (
BANG_OLUFSEN_REPEAT_FROM_HA,
BANG_OLUFSEN_REPEAT_TO_HA,
BANG_OLUFSEN_STATES,
BEOLINK_JOIN_SOURCES,
BEOLINK_JOIN_SOURCES_TO_UPPER,
CONF_BEOLINK_JID,
CONNECTION_STATUS,
DOMAIN,
@ -135,7 +137,10 @@ async def async_setup_entry(
platform.async_register_entity_service(
name="beolink_join",
schema={vol.Optional("beolink_jid"): jid_regex},
schema={
vol.Optional("beolink_jid"): jid_regex,
vol.Optional("source_id"): vol.In(BEOLINK_JOIN_SOURCES),
},
func="async_beolink_join",
)
@ -985,12 +990,23 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
await self.async_beolink_leave()
# Custom actions:
async def async_beolink_join(self, beolink_jid: str | None = None) -> None:
async def async_beolink_join(
self, beolink_jid: str | None = None, source_id: str | None = None
) -> None:
"""Join a Beolink multi-room experience."""
# Touch to join
if beolink_jid is None:
await self._client.join_latest_beolink_experience()
else:
# Join a peer
elif beolink_jid and source_id is None:
await self._client.join_beolink_peer(jid=beolink_jid)
# Join a peer and select specific source
elif beolink_jid and source_id:
# Beolink Converter NL/ML sources need to be in upper case
if source_id in BEOLINK_JOIN_SOURCES_TO_UPPER:
source_id = source_id.upper()
await self._client.join_beolink_peer(jid=beolink_jid, source=source_id)
async def async_beolink_expand(
self, beolink_jids: list[str] | None = None, all_discovered: bool = False

View File

@ -48,6 +48,23 @@ beolink_join:
example: 1111.2222222.33333333@products.bang-olufsen.com
selector:
text:
source_id:
required: false
example: tidal
selector:
select:
translation_key: "source_ids"
options:
- beoradio
- deezer
- spotify
- tidal
- radio
- tp1
- tp2
- cd
- aux_a
- ph
beolink_leave:
target:

View File

@ -29,6 +29,22 @@
}
}
},
"selector": {
"source_ids": {
"options": {
"beoradio": "ASE Beoradio",
"deezer": "ASE / Mozart Deezer",
"spotify": "ASE / Mozart Spotify",
"tidal": "Mozart Tidal",
"aux_a": "Beolink Converter NL/ML AUX_A",
"cd": "Beolink Converter NL/ML CD",
"ph": "Beolink Converter NL/ML PH",
"radio": "Beolink Converter NL/ML RADIO",
"tp1": "Beolink Converter NL/ML TP1",
"tp2": "Beolink Converter NL/ML TP2"
}
}
},
"services": {
"beolink_allstandby": {
"name": "Beolink all standby",
@ -61,6 +77,10 @@
"beolink_jid": {
"name": "Beolink JID",
"description": "Manually specify Beolink JID to join."
},
"source_id": {
"name": "Source",
"description": "Specify which source to join, behavior varies between hardware platforms. Source names prefaced by a platform name can only be used when connecting to that platform. For example \"ASE Beoradio\" can only be used when joining an ASE device, while ”ASE / Mozart Deezer” can be used with ASE or Mozart devices. A defined Beolink JID is required."
}
},
"sections": {

View File

@ -243,7 +243,7 @@
'state': 'playing',
})
# ---
# name: test_async_beolink_join
# name: test_async_beolink_join[service_parameters0-method_parameters0]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@ -291,6 +291,240 @@
'state': 'playing',
})
# ---
# name: test_async_beolink_join[service_parameters1-method_parameters1]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'peers': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
}),
}),
'device_class': 'speaker',
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
'media_player.beosound_balance_11111111',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'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)',
'Test Listening Mode (234)',
'Test Listening Mode 2 (345)',
]),
'source_list': list([
'Tidal',
'Line-In',
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
}),
'context': <ANY>,
'entity_id': 'media_player.beosound_balance_11111111',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
# name: test_async_beolink_join[service_parameters2-method_parameters2]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'peers': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
}),
}),
'device_class': 'speaker',
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
'media_player.beosound_balance_11111111',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'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)',
'Test Listening Mode (234)',
'Test Listening Mode 2 (345)',
]),
'source_list': list([
'Tidal',
'Line-In',
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
}),
'context': <ANY>,
'entity_id': 'media_player.beosound_balance_11111111',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
# name: test_async_beolink_join_invalid[service_parameters0-expected_result0]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'peers': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
}),
}),
'device_class': 'speaker',
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
'media_player.beosound_balance_11111111',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
'Test Listening Mode (234)',
'Test Listening Mode 2 (345)',
]),
'source_list': list([
'Tidal',
'Line-In',
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
}),
'context': <ANY>,
'entity_id': 'media_player.beosound_balance_11111111',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
# name: test_async_beolink_join_invalid[service_parameters1-expected_result1]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'peers': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
}),
}),
'device_class': 'speaker',
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
'media_player.beosound_balance_11111111',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
'Test Listening Mode (234)',
'Test Listening Mode 2 (345)',
]),
'source_list': list([
'Tidal',
'Line-In',
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
}),
'context': <ANY>,
'entity_id': 'media_player.beosound_balance_11111111',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
# name: test_async_beolink_join_invalid[service_parameters2-expected_result2]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
'listeners': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'peers': dict({
'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
}),
}),
'device_class': 'speaker',
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
'media_player.beosound_balance_11111111',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': <MediaType.MUSIC: 'music'>,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
'Test Listening Mode (234)',
'Test Listening Mode 2 (345)',
]),
'source_list': list([
'Tidal',
'Line-In',
'HDMI A',
]),
'supported_features': <MediaPlayerEntityFeature: 2095933>,
}),
'context': <ANY>,
'entity_id': 'media_player.beosound_balance_11111111',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
# name: test_async_beolink_unexpand
StateSnapshot({
'attributes': ReadOnlyDict({

View File

@ -18,6 +18,7 @@ from mozart_api.models import (
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from voluptuous import Invalid, MultipleInvalid
from homeassistant.components.bang_olufsen.const import (
BANG_OLUFSEN_REPEAT_FROM_HA,
@ -1523,13 +1524,38 @@ async def test_async_unjoin_player(
assert states == snapshot(exclude=props("media_position_updated_at"))
@pytest.mark.parametrize(
(
"service_parameters",
"method_parameters",
),
[
# Defined JID
(
{"beolink_jid": TEST_JID_2},
{"jid": TEST_JID_2},
),
# Defined JID and source
(
{"beolink_jid": TEST_JID_2, "source_id": TEST_SOURCE.id},
{"jid": TEST_JID_2, "source": TEST_SOURCE.id},
),
# Defined JID and Beolink Converter NL/ML source
(
{"beolink_jid": TEST_JID_2, "source_id": "cd"},
{"jid": TEST_JID_2, "source": "CD"},
),
],
)
async def test_async_beolink_join(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_mozart_client: AsyncMock,
mock_config_entry: MockConfigEntry,
service_parameters: dict[str, str],
method_parameters: dict[str, str],
) -> None:
"""Test async_beolink_join with defined JID."""
"""Test async_beolink_join with defined JID and JID and source."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@ -1537,14 +1563,61 @@ async def test_async_beolink_join(
await hass.services.async_call(
DOMAIN,
"beolink_join",
{
ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID,
"beolink_jid": TEST_JID_2,
},
{ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, **service_parameters},
blocking=True,
)
mock_mozart_client.join_beolink_peer.assert_called_once_with(jid=TEST_JID_2)
mock_mozart_client.join_beolink_peer.assert_called_once_with(**method_parameters)
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states == snapshot(exclude=props("media_position_updated_at"))
@pytest.mark.parametrize(
(
"service_parameters",
"expected_result",
),
[
# Defined invalid JID
(
{"beolink_jid": "not_a_jid"},
pytest.raises(Invalid),
),
# Defined invalid source
(
{"source_id": "invalid_source"},
pytest.raises(MultipleInvalid),
),
# Defined invalid JID and invalid source
(
{"beolink_jid": "not_a_jid", "source_id": "invalid_source"},
pytest.raises(MultipleInvalid),
),
],
)
async def test_async_beolink_join_invalid(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_mozart_client: AsyncMock,
mock_config_entry: MockConfigEntry,
service_parameters: dict[str, str],
expected_result: AbstractContextManager,
) -> None:
"""Test invalid async_beolink_join calls with defined JID or source ID."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
with expected_result:
await hass.services.async_call(
DOMAIN,
"beolink_join",
{ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, **service_parameters},
blocking=True,
)
mock_mozart_client.join_beolink_peer.assert_not_called()
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states == snapshot(exclude=props("media_position_updated_at"))