Add Bang & Olufsen button Event entities (#127550)

* Add button events

* Remove unused common keys
Rename Preset to Favourite

* Add event testing

* Add check for Beoconnect Core

* Rename device controls

* Add test for Beoconnect core event entity creation

* Fix config entry type

* Add a type checking check before assertion

* Add icon translations

* Remove useless defined icons

* Remove base event class

* Update homeassistant/components/bang_olufsen/event.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

---------

Co-authored-by: Josef Zweck <josef@zweck.dev>
This commit is contained in:
Markus Jacobsen 2024-12-31 21:55:24 +01:00 committed by GitHub
parent f1ad3040b8
commit 2054988790
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 412 additions and 15 deletions

View File

@ -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:

View File

@ -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",

View File

@ -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()

View File

@ -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": {

View File

@ -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:

View File

@ -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,

View File

@ -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,
}

View File

@ -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)"]
)

View File

@ -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)