diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index 92f29c071da..70f9cc7b572 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -34,7 +34,7 @@ class BeoData: type BeoConfigEntry = ConfigEntry[BeoData] -PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool: diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 5dce19cf8cb..a029003e34c 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -115,6 +115,7 @@ class WebsocketNotification(StrEnum): """Enum for WebSocket notification types.""" ACTIVE_LISTENING_MODE = "active_listening_mode" + BATTERY = "battery" BEO_REMOTE_BUTTON = "beo_remote_button" BUTTON = "button" PLAYBACK_ERROR = "playback_error" diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py index 524cd3f47e6..d5be9cf2e92 100644 --- a/homeassistant/components/bang_olufsen/diagnostics.py +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.event import DOMAIN as EVENT_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -55,6 +56,19 @@ async def async_get_config_entry_diagnostics( # Get remotes for remote in await get_remotes(config_entry.runtime_data.client): + # Get Battery Sensor states + if entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"{remote.serial_number}_{config_entry.unique_id}_remote_battery_level", + ): + if state := hass.states.get(entity_id): + state_dict = dict(state.as_dict()) + + # Remove context as it is not relevant + state_dict.pop("context") + data[f"remote_{remote.serial_number}_battery_level"] = state_dict + # Get key Event entity states (if enabled) for key_type in get_remote_keys(): if entity_id := entity_registry.async_get_entity_id( @@ -72,4 +86,15 @@ async def async_get_config_entry_diagnostics( # Add remote Mozart model data[f"remote_{remote.serial_number}"] = dict(remote) + # Get Mozart battery entity + if entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_battery_level" + ): + if state := hass.states.get(entity_id): + state_dict = dict(state.as_dict()) + + # Remove context as it is not relevant + state_dict.pop("context") + data["battery_level"] = state_dict + return data diff --git a/homeassistant/components/bang_olufsen/sensor.py b/homeassistant/components/bang_olufsen/sensor.py new file mode 100644 index 00000000000..9ff703112c3 --- /dev/null +++ b/homeassistant/components/bang_olufsen/sensor.py @@ -0,0 +1,139 @@ +"""Sensor entities for the Bang & Olufsen integration.""" + +from __future__ import annotations + +import contextlib +from datetime import timedelta + +from aiohttp import ClientConnectorError +from mozart_api.exceptions import ApiException +from mozart_api.models import BatteryState, PairedRemote + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BeoConfigEntry +from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification +from .entity import BeoEntity +from .util import get_remotes, supports_battery + +SCAN_INTERVAL = timedelta(minutes=15) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BeoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sensor entities from config entry.""" + entities: list[BeoSensor] = [] + + # Check for Mozart device with battery + if await supports_battery(config_entry.runtime_data.client): + entities.append(BeoSensorBatteryLevel(config_entry)) + + # Add any Beoremote One remotes + entities.extend( + [ + BeoSensorRemoteBatteryLevel(config_entry, remote) + for remote in (await get_remotes(config_entry.runtime_data.client)) + ] + ) + + async_add_entities(entities, update_before_add=True) + + +class BeoSensor(SensorEntity, BeoEntity): + """Base Bang & Olufsen Sensor.""" + + def __init__(self, config_entry: BeoConfigEntry) -> None: + """Initialize Sensor.""" + super().__init__(config_entry, config_entry.runtime_data.client) + + +class BeoSensorBatteryLevel(BeoSensor): + """Battery level Sensor for Mozart devices.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, config_entry: BeoConfigEntry) -> None: + """Init the battery level Sensor.""" + super().__init__(config_entry) + + self._attr_unique_id = f"{self._unique_id}_battery_level" + + async def async_added_to_hass(self) -> None: + """Turn on the dispatchers.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}", + self._async_update_connection_state, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}", + self._update_battery, + ) + ) + + async def _update_battery(self, data: BatteryState) -> None: + """Update sensor value.""" + self._attr_native_value = data.battery_level + self.async_write_ha_state() + + +class BeoSensorRemoteBatteryLevel(BeoSensor): + """Battery level Sensor for the Beoremote One.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_should_poll = True + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, config_entry: BeoConfigEntry, remote: PairedRemote) -> None: + """Init the battery level Sensor.""" + super().__init__(config_entry) + # Serial number is not None, as the remote object is provided by get_remotes + assert remote.serial_number + + self._attr_unique_id = ( + f"{remote.serial_number}_{self._unique_id}_remote_battery_level" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")} + ) + self._attr_native_value = remote.battery_level + self._remote = remote + + async def async_added_to_hass(self) -> None: + """Turn on the dispatchers.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}", + self._async_update_connection_state, + ) + ) + + async def async_update(self) -> None: + """Poll battery status.""" + with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError): + for remote in await get_remotes(self._client): + if remote.serial_number == self._remote.serial_number: + self._attr_native_value = remote.battery_level + break diff --git a/homeassistant/components/bang_olufsen/util.py b/homeassistant/components/bang_olufsen/util.py index 4ca0432529d..540837441a4 100644 --- a/homeassistant/components/bang_olufsen/util.py +++ b/homeassistant/components/bang_olufsen/util.py @@ -84,3 +84,10 @@ def get_remote_keys() -> list[str]: for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS) ], ] + + +async def supports_battery(client: MozartClient) -> bool: + """Get if a Mozart device has a battery.""" + battery_state = await client.get_battery_state() + + return battery_state.state != "BatteryNotPresent" diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 3b47fc631c6..09e27ea5f88 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -6,6 +6,7 @@ import logging from typing import TYPE_CHECKING from mozart_api.models import ( + BatteryState, BeoRemoteButton, ButtonEvent, ListeningModeProps, @@ -60,6 +61,7 @@ class BeoWebsocket(BeoBase): self._client.get_active_listening_mode_notifications( self.on_active_listening_mode ) + self._client.get_battery_notifications(self.on_battery_notification) self._client.get_beo_remote_button_notifications( self.on_beo_remote_button_notification ) @@ -115,6 +117,14 @@ class BeoWebsocket(BeoBase): notification, ) + def on_battery_notification(self, notification: BatteryState) -> None: + """Send battery dispatch.""" + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}", + notification, + ) + def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None: """Send beo_remote_button dispatch.""" if TYPE_CHECKING: diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 5adbf717513..bd9cd7be137 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch from mozart_api.models import ( Action, + BatteryState, BeolinkPeer, BeolinkSelf, ContentItem, @@ -34,6 +35,7 @@ from homeassistant.components.bang_olufsen.const import DOMAIN from homeassistant.core import HomeAssistant from .const import ( + TEST_BATTERY, TEST_DATA_CREATE_ENTRY, TEST_DATA_CREATE_ENTRY_2, TEST_DATA_CREATE_ENTRY_3, @@ -125,6 +127,7 @@ async def mock_websocket_connection( playback_metadata_callback = ( mock_mozart_client.get_playback_metadata_notifications.call_args[0][0] ) + battery_callback = mock_mozart_client.get_battery_notifications.call_args[0][0] # Trigger callbacks. Try to use existing data volume_callback(mock_mozart_client.get_product_state.return_value.volume) @@ -137,6 +140,10 @@ async def mock_websocket_connection( playback_metadata_callback( mock_mozart_client.get_product_state.return_value.playback.metadata ) + + # This should not affect non-battery devices. + battery_callback(TEST_BATTERY) + await hass.async_block_till_done() @@ -403,6 +410,14 @@ def mock_mozart_client() -> Generator[AsyncMock]: ) ] ) + client.get_battery_state = AsyncMock() + client.get_battery_state.return_value = BatteryState( + battery_level=0, + is_charging=False, + remaining_charging_time_minutes=0, + remaining_playing_time_minutes=0, + state="BatteryNotPresent", + ) client.post_standby = AsyncMock() client.set_current_volume_level = AsyncMock() client.set_volume_mute = AsyncMock() diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index e0726778b4a..129c9bebee0 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -6,6 +6,7 @@ from unittest.mock import Mock from mozart_api.exceptions import ApiException from mozart_api.models import ( Action, + BatteryState, ListeningModeRef, OverlayPlayRequest, OverlayPlayRequestTextToSpeechTextToSpeech, @@ -71,14 +72,18 @@ TEST_NAME_4 = f"{TEST_MODEL_A5}-{TEST_SERIAL_NUMBER_4}" TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_4}@products.bang-olufsen.com" TEST_MEDIA_PLAYER_ENTITY_ID_4 = f"media_player.beosound_a5_{TEST_SERIAL_NUMBER_4}" TEST_HOST_4 = "192.168.0.4" +TEST_BATTERY_A5_SENSOR_ENTITY_ID = f"sensor.beosound_a5_{TEST_SERIAL_NUMBER_4}_battery" # Beoremote One TEST_REMOTE_SERIAL = "55555555" TEST_REMOTE_SERIAL_PAIRED = f"{TEST_REMOTE_SERIAL}_{TEST_SERIAL_NUMBER}" TEST_REMOTE_SW_VERSION = "1.0.0" -TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause" TEST_REMOTE_KEY_EVENT_ENTITY_ID = "event.beoremote_one_55555555_11111111_control_play" +TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID = ( + "sensor.beoremote_one_55555555_11111111_battery" +) +TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause" TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." @@ -255,3 +260,10 @@ TEST_SOUND_MODES = [ TEST_ACTIVE_SOUND_MODE_NAME_2, f"{TEST_SOUND_MODE_NAME} 2 (345)", ] +TEST_BATTERY = BatteryState( + battery_level=5, + is_charging=False, + remaining_charging_time_minutes=0, + remaining_playing_time_minutes=0, + state="BatteryVeryLow", +) diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index 2ffef509be7..b5b4a97c020 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -106,6 +106,117 @@ 'entity_id': 'event.beoremote_one_55555555_11111111_control_play', 'state': 'unknown', }), + 'remote_55555555_battery_level': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Beoremote One-55555555-11111111 Battery', + 'state_class': 'measurement', + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.beoremote_one_55555555_11111111_battery', + 'state': '50', + }), + 'websocket_connected': False, + }) +# --- +# name: test_async_get_config_entry_diagnostics_with_battery + dict({ + 'battery_level': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living room Balance Battery', + 'state_class': 'measurement', + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.beosound_a5_44444444_battery', + 'state': '5', + }), + 'config_entry': dict({ + 'data': dict({ + 'host': '192.168.0.4', + 'jid': '1111.1111111.44444444@products.bang-olufsen.com', + 'model': 'Beosound A5', + 'name': 'Beosound A5-44444444', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'bang_olufsen', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Beosound A5-44444444', + 'unique_id': '44444444', + 'version': 1, + }), + 'media_player': dict({ + 'attributes': dict({ + 'beolink': dict({ + 'listeners': dict({ + 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_a5_44444444', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'media_player.beosound_a5_44444444', + ]), + 'media_content_type': 'music', + 'repeat': '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': 2095933, + }), + 'entity_id': 'media_player.beosound_a5_44444444', + 'state': 'playing', + }), + 'remote_55555555': dict({ + 'address': '', + 'app_version': '1.0.0', + 'battery_level': 50, + 'connected': True, + 'db_version': None, + 'last_seen': None, + 'name': 'BEORC', + 'serial_number': '55555555', + 'updated': None, + }), + 'remote_55555555_battery_level': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Beoremote One-55555555-44444444 Battery', + 'state_class': 'measurement', + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.beoremote_one_55555555_44444444_battery', + 'state': '50', + }), 'websocket_connected': False, }) # --- diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr index a4ca624a934..ab83a00f42e 100644 --- a/tests/components/bang_olufsen/snapshots/test_event.ambr +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -100,6 +100,8 @@ 'event.beoremote_one_55555555_44444444_control_function_25', 'event.beoremote_one_55555555_44444444_control_function_26', 'event.beoremote_one_55555555_44444444_control_function_27', + 'sensor.beosound_a5_44444444_battery', + 'sensor.beoremote_one_55555555_44444444_battery', 'media_player.beosound_a5_44444444', ]) # --- @@ -205,6 +207,7 @@ 'event.beoremote_one_55555555_11111111_control_function_25', 'event.beoremote_one_55555555_11111111_control_function_26', 'event.beoremote_one_55555555_11111111_control_function_27', + 'sensor.beoremote_one_55555555_11111111_battery', 'media_player.beosound_balance_11111111', ]) # --- @@ -308,6 +311,7 @@ 'event.beoremote_one_55555555_33333333_control_function_25', 'event.beoremote_one_55555555_33333333_control_function_26', 'event.beoremote_one_55555555_33333333_control_function_27', + 'sensor.beoremote_one_55555555_33333333_battery', 'media_player.beosound_premiere_33333333', ]) # --- diff --git a/tests/components/bang_olufsen/snapshots/test_websocket.ambr b/tests/components/bang_olufsen/snapshots/test_websocket.ambr index cbbc742d036..642854fb9ed 100644 --- a/tests/components/bang_olufsen/snapshots/test_websocket.ambr +++ b/tests/components/bang_olufsen/snapshots/test_websocket.ambr @@ -101,6 +101,7 @@ 'event.beoremote_one_55555555_11111111_control_function_25', 'event.beoremote_one_55555555_11111111_control_function_26', 'event.beoremote_one_55555555_11111111_control_function_27', + 'sensor.beoremote_one_55555555_11111111_battery', 'media_player.beosound_balance_11111111', ]) # --- @@ -206,6 +207,7 @@ 'event.beoremote_one_55555555_11111111_control_function_25', 'event.beoremote_one_55555555_11111111_control_function_26', 'event.beoremote_one_55555555_11111111_control_function_27', + 'sensor.beoremote_one_55555555_11111111_battery', 'media_player.beosound_balance_11111111', 'event.beoremote_one_66666666_11111111_light_blue', 'event.beoremote_one_66666666_11111111_light_digit_0', @@ -297,6 +299,7 @@ 'event.beoremote_one_66666666_11111111_control_function_25', 'event.beoremote_one_66666666_11111111_control_function_26', 'event.beoremote_one_66666666_11111111_control_function_27', + 'sensor.beoremote_one_66666666_11111111_battery', ]) # --- # name: test_on_remote_control_unpaired diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index 197a8335244..2d4bac00122 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -1,5 +1,6 @@ """Test bang_olufsen config entry diagnostics.""" +from mozart_api.models import BatteryState from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -51,3 +52,39 @@ async def test_async_get_config_entry_diagnostics( "modified_at", ) ) + + +async def test_async_get_config_entry_diagnostics_with_battery( + hass: HomeAssistant, + entity_registry: EntityRegistry, + hass_client: ClientSessionGenerator, + mock_config_entry_a5: MockConfigEntry, + mock_mozart_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics for devices with a battery.""" + mock_mozart_client.get_battery_state.return_value = BatteryState( + battery_level=1, state="BatteryVeryLow" + ) + + # Load entry + mock_config_entry_a5.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_a5.entry_id) + await mock_websocket_connection(hass, mock_mozart_client) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry_a5 + ) + + assert result == snapshot( + exclude=props( + "created_at", + "entry_id", + "id", + "last_changed", + "last_reported", + "last_updated", + "media_position_updated_at", + "modified_at", + ) + ) diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index d7744cb52fc..c228efb0d11 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from .conftest import mock_websocket_connection from .const import ( + TEST_BATTERY, TEST_BUTTON_EVENT_ENTITY_ID, TEST_REMOTE_KEY_EVENT_ENTITY_ID, TEST_SERIAL_NUMBER_3, @@ -130,6 +131,7 @@ async def test_button_event_creation_a5( snapshot: SnapshotAssertion, ) -> None: """Test Microphone button event entity is not created when using a Beosound A5.""" + mock_mozart_client.get_battery_state.return_value = TEST_BATTERY await _check_button_event_creation( hass, diff --git a/tests/components/bang_olufsen/test_sensor.py b/tests/components/bang_olufsen/test_sensor.py new file mode 100644 index 00000000000..d379f874efd --- /dev/null +++ b/tests/components/bang_olufsen/test_sensor.py @@ -0,0 +1,80 @@ +"""Test the bang_olufsen sensor entities.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from mozart_api.models import PairedRemote, PairedRemoteResponse + +from homeassistant.components.bang_olufsen.sensor import SCAN_INTERVAL +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .conftest import mock_websocket_connection +from .const import ( + TEST_BATTERY, + TEST_BATTERY_A5_SENSOR_ENTITY_ID, + TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID, + TEST_REMOTE_SERIAL, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_battery_level( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry_a5: MockConfigEntry, +) -> None: + """Test the battery level entity.""" + # Ensure battery entities are created + mock_mozart_client.get_battery_state.return_value = TEST_BATTERY + + # Load entry + mock_config_entry_a5.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_a5.entry_id) + # Deliberately avoid triggering a battery notification + + assert (states := hass.states.get(TEST_BATTERY_A5_SENSOR_ENTITY_ID)) + assert states.state is STATE_UNKNOWN + + # Check sensor reacts as expected to WebSocket events + await mock_websocket_connection(hass, mock_mozart_client) + + assert (states := hass.states.get(TEST_BATTERY_A5_SENSOR_ENTITY_ID)) + assert states.state == str(TEST_BATTERY.battery_level) + + +async def test_remote_battery_level( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + integration: None, + mock_config_entry: MockConfigEntry, + mock_mozart_client: AsyncMock, +) -> None: + """Test the remote battery level entity.""" + + # Check the default value is set + assert (states := hass.states.get(TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID)) + assert states.state == "50" + + # Change battery level + mock_mozart_client.get_bluetooth_remotes.return_value = PairedRemoteResponse( + items=[ + PairedRemote( + address="", + app_version="1.0.0", + battery_level=45, + connected=True, + serial_number=TEST_REMOTE_SERIAL, + name="BEORC", + ) + ] + ) + + # Trigger poll update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (states := hass.states.get(TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID)) + assert states.state == "45" diff --git a/tests/components/bang_olufsen/test_websocket.py b/tests/components/bang_olufsen/test_websocket.py index 22e98d17fd3..fb53f9eef94 100644 --- a/tests/components/bang_olufsen/test_websocket.py +++ b/tests/components/bang_olufsen/test_websocket.py @@ -130,7 +130,7 @@ async def test_on_remote_control_already_added( await hass.config_entries.async_setup(mock_config_entry.entry_id) # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 1 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 3 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) # Check number of entities (remote and button events and media_player) @@ -149,7 +149,7 @@ async def test_on_remote_control_already_added( await hass.async_block_till_done() # Check device and API call count (triggered once by the WebSocket notification) - assert mock_mozart_client.get_bluetooth_remotes.call_count == 2 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 4 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) # Check number of entities (remote and button events and media_player) @@ -176,7 +176,7 @@ async def test_on_remote_control_paired( await hass.config_entries.async_setup(mock_config_entry.entry_id) # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 1 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 3 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) # Check number of entities (button and remote events and media_player) @@ -217,7 +217,7 @@ async def test_on_remote_control_paired( await hass.async_block_till_done() # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 3 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 8 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) assert device_registry.async_get_device( {(DOMAIN, f"66666666_{TEST_SERIAL_NUMBER}")} @@ -257,7 +257,7 @@ async def test_on_remote_control_unpaired( await hass.config_entries.async_setup(mock_config_entry.entry_id) # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 1 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 3 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) # Check number of entities (button and remote events and media_player) @@ -280,7 +280,7 @@ async def test_on_remote_control_unpaired( await hass.async_block_till_done() # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 3 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 6 assert ( device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) is None ) diff --git a/tests/components/bang_olufsen/util.py b/tests/components/bang_olufsen/util.py index 721f058fc27..03c7b0092c7 100644 --- a/tests/components/bang_olufsen/util.py +++ b/tests/components/bang_olufsen/util.py @@ -11,6 +11,7 @@ from homeassistant.components.bang_olufsen.const import ( ) from .const import ( + TEST_BATTERY_A5_SENSOR_ENTITY_ID, TEST_MEDIA_PLAYER_ENTITY_ID, TEST_MEDIA_PLAYER_ENTITY_ID_2, TEST_MEDIA_PLAYER_ENTITY_ID_3, @@ -51,6 +52,7 @@ def get_a5_entity_ids() -> list[str]: """Return a list of entity_ids that a Beosound A5 provides.""" buttons = [ TEST_MEDIA_PLAYER_ENTITY_ID_4, + TEST_BATTERY_A5_SENSOR_ENTITY_ID, *_get_button_entity_ids("beosound_a5_44444444"), ] buttons.remove("event.beosound_a5_44444444_microphone") @@ -66,7 +68,9 @@ def get_remote_entity_ids( remote_serial: str = TEST_REMOTE_SERIAL, device_serial: str = TEST_SERIAL_NUMBER ) -> list[str]: """Return a list of entity_ids that the Beoremote One provides.""" - entity_ids: list[str] = [] + entity_ids: list[str] = [ + f"sensor.beoremote_one_{remote_serial}_{device_serial}_battery" + ] # Add remote light key Event entity ids entity_ids.extend(