diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index be99f8b5b7d4dc26f6d11a30dada1cabae1af67f..b80e625e8d4dcb27c446275e6c39aaa2d64547c0 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -34,7 +34,7 @@ class BangOlufsenData: type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData] -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool: diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 9f0649e610b2da5e5e445634bd24c6770499f653..c5ee5d1a26e34f410c41f24728e24e1f6f721b7e 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -79,6 +79,7 @@ class WebsocketNotification(StrEnum): """Enum for WebSocket notification types.""" ACTIVE_LISTENING_MODE = "active_listening_mode" + BUTTON = "button" PLAYBACK_ERROR = "playback_error" PLAYBACK_METADATA = "playback_metadata" PLAYBACK_PROGRESS = "playback_progress" @@ -203,14 +204,60 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray( ), ] ) - +# Map for storing compatibility of devices. + +MODEL_SUPPORT_DEVICE_BUTTONS: Final[str] = "device_buttons" + +MODEL_SUPPORT_MAP = { + MODEL_SUPPORT_DEVICE_BUTTONS: ( + BangOlufsenModel.BEOLAB_8, + BangOlufsenModel.BEOLAB_28, + BangOlufsenModel.BEOSOUND_2, + BangOlufsenModel.BEOSOUND_A5, + BangOlufsenModel.BEOSOUND_A9, + BangOlufsenModel.BEOSOUND_BALANCE, + BangOlufsenModel.BEOSOUND_EMERGE, + BangOlufsenModel.BEOSOUND_LEVEL, + BangOlufsenModel.BEOSOUND_THEATRE, + ) +} # Device events BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event" +# Dict used to translate native Bang & Olufsen event names to string.json compatible ones +EVENT_TRANSLATION_MAP: dict[str, str] = { + "shortPress (Release)": "short_press_release", + "longPress (Timeout)": "long_press_timeout", + "longPress (Release)": "long_press_release", + "veryLongPress (Timeout)": "very_long_press_timeout", + "veryLongPress (Release)": "very_long_press_release", +} CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS" +DEVICE_BUTTONS: Final[list[str]] = [ + "Bluetooth", + "Microphone", + "Next", + "PlayPause", + "Preset1", + "Preset2", + "Preset3", + "Preset4", + "Previous", + "Volume", +] + + +DEVICE_BUTTON_EVENTS: Final[list[str]] = [ + "short_press_release", + "long_press_timeout", + "long_press_release", + "very_long_press_timeout", + "very_long_press_release", +] + # Beolink Converter NL/ML sources need to be transformed to upper case BEOLINK_JOIN_SOURCES_TO_UPPER = ( "aux_a", diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py new file mode 100644 index 0000000000000000000000000000000000000000..80ad4060c5e12989940b3f2572a23c8e468f7cff --- /dev/null +++ b/homeassistant/components/bang_olufsen/event.py @@ -0,0 +1,76 @@ +"""Event entities for the Bang & Olufsen integration.""" + +from __future__ import annotations + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BangOlufsenConfigEntry +from .const import ( + CONNECTION_STATUS, + DEVICE_BUTTON_EVENTS, + DEVICE_BUTTONS, + MODEL_SUPPORT_DEVICE_BUTTONS, + MODEL_SUPPORT_MAP, + WebsocketNotification, +) +from .entity import BangOlufsenEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BangOlufsenConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sensor entities from config entry.""" + + if config_entry.data[CONF_MODEL] in MODEL_SUPPORT_MAP[MODEL_SUPPORT_DEVICE_BUTTONS]: + async_add_entities( + BangOlufsenButtonEvent(config_entry, button_type) + for button_type in DEVICE_BUTTONS + ) + + +class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity): + """Event class for Button events.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_entity_registry_enabled_default = False + _attr_event_types = DEVICE_BUTTON_EVENTS + + def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None: + """Initialize Button.""" + super().__init__(config_entry, config_entry.runtime_data.client) + + self._attr_unique_id = f"{self._unique_id}_{button_type}" + + # Make the native button name Home Assistant compatible + self._attr_translation_key = button_type.lower() + + self._button_type = button_type + + async def async_added_to_hass(self) -> None: + """Listen to WebSocket button events.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{CONNECTION_STATUS}", + self._async_update_connection_state, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebsocketNotification.BUTTON}_{self._button_type}", + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: str) -> None: + """Handle event.""" + self._trigger_event(event) + self.async_write_ha_state() diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index b4aac78756c47f7ae9bedcbab711e62dc13bfac1..57ab828f9fbe1934ddbfd41ded88711ec71ae0bc 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -1,7 +1,12 @@ { "common": { + "jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.", "jid_options_name": "JID options", - "jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity." + "long_press_release": "Release of long press", + "long_press_timeout": "Long press", + "short_press_release": "Release of short press", + "very_long_press_release": "Release of very long press", + "very_long_press_timeout": "Very long press" }, "config": { "error": { @@ -29,6 +34,150 @@ } } }, + "entity": { + "event": { + "bluetooth": { + "name": "Bluetooth", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "microphone": { + "name": "Microphone", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "next": { + "name": "Next", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "playpause": { + "name": "Play / Pause", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "preset1": { + "name": "Favourite 1", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "preset2": { + "name": "Favourite 2", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "preset3": { + "name": "Favourite 3", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "preset4": { + "name": "Favourite 4", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "previous": { + "name": "Previous", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "volume": { + "name": "Volume", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + } + } + }, "selector": { "source_ids": { "options": { diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index bc817226b61e306daa3ff405c41f8feb4e96f12a..a6ae0358842a2535d7d50c914e42afa0ac487ebe 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -3,8 +3,10 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING from mozart_api.models import ( + ButtonEvent, ListeningModeProps, PlaybackContentMetadata, PlaybackError, @@ -26,6 +28,7 @@ from homeassistant.util.enum import try_parse_enum from .const import ( BANG_OLUFSEN_WEBSOCKET_EVENT, CONNECTION_STATUS, + EVENT_TRANSLATION_MAP, WebsocketNotification, ) from .entity import BangOlufsenBase @@ -54,6 +57,8 @@ class BangOlufsenWebsocket(BangOlufsenBase): self._client.get_active_listening_mode_notifications( self.on_active_listening_mode ) + self._client.get_button_notifications(self.on_button_notification) + self._client.get_playback_error_notifications( self.on_playback_error_notification ) @@ -104,6 +109,19 @@ class BangOlufsenWebsocket(BangOlufsenBase): notification, ) + def on_button_notification(self, notification: ButtonEvent) -> None: + """Send button dispatch.""" + # State is expected to always be available. + if TYPE_CHECKING: + assert notification.state + + # Send to event entity + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}", + EVENT_TRANSLATION_MAP[notification.state], + ) + def on_notification_notification( self, notification: WebsocketNotificationTag ) -> None: diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index cbde856ff89e600506baff8e339da1d4d19ccbb9..700d085dd11edf7f196a79fd6e7476a72a9cca3e 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -56,7 +56,7 @@ from tests.common import MockConfigEntry @pytest.fixture def mock_config_entry() -> MockConfigEntry: - """Mock config entry.""" + """Mock config entry for Beosound Balance.""" return MockConfigEntry( domain=DOMAIN, unique_id=TEST_SERIAL_NUMBER, @@ -66,8 +66,8 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_config_entry_2() -> MockConfigEntry: - """Mock config entry.""" +def mock_config_entry_core() -> MockConfigEntry: + """Mock config entry for Beoconnect Core.""" return MockConfigEntry( domain=DOMAIN, unique_id=TEST_SERIAL_NUMBER_2, diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 6602a898eb688a34dcfd942ce9a5a0812feea1fb..27292e5a28cb757476589dd312a7d2329f5a76a9 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -38,6 +38,7 @@ TEST_HOST = "192.168.0.1" TEST_HOST_INVALID = "192.168.0" TEST_HOST_IPV6 = "1111:2222:3333:4444:5555:6666:7777:8888" TEST_MODEL_BALANCE = "Beosound Balance" +TEST_MODEL_CORE = "Beoconnect Core" TEST_MODEL_THEATRE = "Beosound Theatre" TEST_MODEL_LEVEL = "Beosound Level" TEST_SERIAL_NUMBER = "11111111" @@ -65,6 +66,9 @@ TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.44444444@products.bang-oluf TEST_MEDIA_PLAYER_ENTITY_ID_4 = "media_player.beosound_balance_44444444" TEST_HOST_4 = "192.168.0.4" + +TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause" + TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." TEST_NAME_ZEROCONF = TEST_NAME.replace(" ", "-") + "." + TEST_TYPE_ZEROCONF @@ -81,7 +85,7 @@ TEST_DATA_CREATE_ENTRY = { } TEST_DATA_CREATE_ENTRY_2 = { CONF_HOST: TEST_HOST, - CONF_MODEL: TEST_MODEL_BALANCE, + CONF_MODEL: TEST_MODEL_CORE, CONF_BEOLINK_JID: TEST_JID_2, CONF_NAME: TEST_NAME_2, } diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py new file mode 100644 index 0000000000000000000000000000000000000000..d58e5d2219b2cf845ac0bee97348b5b20a779146 --- /dev/null +++ b/tests/components/bang_olufsen/test_event.py @@ -0,0 +1,103 @@ +"""Test the bang_olufsen event entities.""" + +from unittest.mock import AsyncMock + +from inflection import underscore +from mozart_api.models import ButtonEvent + +from homeassistant.components.bang_olufsen.const import ( + DEVICE_BUTTON_EVENTS, + DEVICE_BUTTONS, + EVENT_TRANSLATION_MAP, +) +from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .const import TEST_BUTTON_EVENT_ENTITY_ID + +from tests.common import MockConfigEntry + + +async def test_button_event_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mozart_client: AsyncMock, + entity_registry: EntityRegistry, +) -> None: + """Test button event entities are created.""" + + # Load entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Add Button Event entity ids + entity_ids = [ + f"event.beosound_balance_11111111_{underscore(button_type)}".replace( + "preset", "preset_" + ) + for button_type in DEVICE_BUTTONS + ] + + # Check that the entities are available + for entity_id in entity_ids: + entity_registry.async_get(entity_id) + + +async def test_button_event_creation_beoconnect_core( + hass: HomeAssistant, + mock_config_entry_core: MockConfigEntry, + mock_mozart_client: AsyncMock, + entity_registry: EntityRegistry, +) -> None: + """Test button event entities are not created when using a Beoconnect Core.""" + + # Load entry + mock_config_entry_core.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_core.entry_id) + + # Add Button Event entity ids + entity_ids = [ + f"event.beosound_balance_11111111_{underscore(button_type)}".replace( + "preset", "preset_" + ) + for button_type in DEVICE_BUTTONS + ] + + # Check that the entities are unavailable + for entity_id in entity_ids: + assert not entity_registry.async_get(entity_id) + + +async def test_button( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mozart_client: AsyncMock, + entity_registry: EntityRegistry, +) -> None: + """Test button event entity.""" + # Load entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Enable the entity + entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) + hass.config_entries.async_schedule_reload(mock_config_entry.entry_id) + + assert (states := hass.states.get(TEST_BUTTON_EVENT_ENTITY_ID)) + assert states.state is STATE_UNKNOWN + assert states.attributes[ATTR_EVENT_TYPES] == list(DEVICE_BUTTON_EVENTS) + + # Check button reacts as expected to WebSocket events + notification_callback = mock_mozart_client.get_button_notifications.call_args[0][0] + + notification_callback(ButtonEvent(button="PlayPause", state="shortPress (Release)")) + await hass.async_block_till_done() + + assert (states := hass.states.get(TEST_BUTTON_EVENT_ENTITY_ID)) + assert states.state is not None + assert ( + states.attributes[ATTR_EVENT_TYPE] + == EVENT_TRANSLATION_MAP["shortPress (Release)"] + ) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 695b086b0a79fc3aefc42d31c0843870d55ef9b7..70b826f0b92a9fc5748684e279fdedf948bdce8f 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -528,7 +528,7 @@ async def test_async_update_beolink_listener( snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_config_entry_2: MockConfigEntry, + mock_config_entry_core: MockConfigEntry, ) -> None: """Test _async_update_beolink as a listener.""" @@ -540,8 +540,8 @@ async def test_async_update_beolink_listener( ) # Add another entity - mock_config_entry_2.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + mock_config_entry_core.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_core.entry_id) # Runs _async_update_beolink playback_metadata_callback( @@ -1386,7 +1386,7 @@ async def test_async_join_players( snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_config_entry_2: MockConfigEntry, + mock_config_entry_core: MockConfigEntry, group_members: list[str], expand_count: int, join_count: int, @@ -1401,8 +1401,8 @@ async def test_async_join_players( ) # Add another entity - mock_config_entry_2.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + mock_config_entry_core.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_core.entry_id) # Set the source to a beolink expandable source source_change_callback(TEST_SOURCE) @@ -1453,7 +1453,7 @@ async def test_async_join_players_invalid( snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_config_entry_2: MockConfigEntry, + mock_config_entry_core: MockConfigEntry, source: Source, group_members: list[str], expected_result: AbstractContextManager, @@ -1468,8 +1468,8 @@ async def test_async_join_players_invalid( mock_mozart_client.get_source_change_notifications.call_args[0][0] ) - mock_config_entry_2.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + mock_config_entry_core.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_core.entry_id) source_change_callback(source)