diff --git a/README.md b/README.md index 5211e9a..039bf20 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Devices that have been tested and _should_ work without any trouble are: - [Beosound Balance](https://www.bang-olufsen.com/en/dk/speakers/beosound-balance) - [Beosound Emerge](https://www.bang-olufsen.com/en/dk/speakers/beosound-emerge) - [Beosound Level](https://www.bang-olufsen.com/en/dk/speakers/beosound-level) -- [Beosound Theatre](https://www.bang-olufsen.com/en/dk/soundbars/beosound-theatre) (Audio only for now) +- [Beosound Theatre](https://www.bang-olufsen.com/en/dk/soundbars/beosound-theatre) ## Configuration @@ -24,16 +24,7 @@ This device can be added to your Home Assistant installation manually by using t ## Entities -This integration adds an array of different useful entities that are generated and added automatically upon setup, customized for the supported features of the device. - -```python -SUPPORTS_PROXIMITY_SENSOR = ( - "BeoLab 28", - "Beosound Balance", - "Beosound Level", - "Beosound Theatre", -) -``` +This integration adds an array of different useful entities that are generated and added automatically upon setup, customized for the supported features of the device. Some of these features, such as `proximity sensor` and `home-control` are manually defined based on model name in the code, as they currently can't be determined in any other way. ### Media Player entity @@ -46,9 +37,6 @@ SUPPORTS_PROXIMITY_SENSOR = ( - Displaying currently playing artist and track - Displaying playback progress - Media seeking (Currently only when using Deezer) -- Device triggers for automations by using device buttons such as Preset1, Bluetooth etc. -- Device triggers for automations by using the Beoremote One Control and Light events -- Events fired upon WebSocket events received - Media browsing: - Playback of local media - Radio Browsing @@ -98,8 +86,6 @@ SUPPORTS_PROXIMITY_SENSOR = ( - media_previous_track - toggle -Some entities are added according to lists of supported devices. These are currently: - ### Binary Sensor entity - Battery Charging (If available) @@ -119,10 +105,13 @@ Some entities are added according to lists of supported devices. These are curre - Battery Level (If available) - Battery Charging Time (If available) - Battery Playing Time (If available) +- Media ID (Disabled by default) +- Input Signal (Disabled by default) ### Select entity - Sound mode (If available) +- Listening Position (If available) ### Switch entity @@ -130,13 +119,14 @@ Some entities are added according to lists of supported devices. These are curre ### Text entity -- Media ID (If available) +- Friendly Name +- Home Control URI (If available) ## Getting Deezer URIs -In order to find Deezer playlist, album URIs and user IDs for Deezer flows, the Deezer website has to be accessed. When navigating to an album, the URL will look something like: "https://www.deezer.com/en/album/ALBUM_ID", and this simply needs to be converted to: `album:ALBUM_ID` and the same applies to playlist, which have the format: `playlist:PLAYLIST_ID`. +In order to find Deezer playlist, album URIs and user IDs for Deezer flows, the Deezer website has to be accessed. When navigating to an album, the URL will look something like: , and this simply needs to be converted to: `album:ALBUM_ID` and the same applies to playlist, which have the format: `playlist:PLAYLIST_ID`. -Additionally a Deezer user ID can be found at https://www.deezer.com/en/profile/USER_ID by selecting the active user in a web browser. +Additionally a Deezer user ID can be found at by selecting the active user in a web browser. Deezer track IDs can currently only easily be found by playing the track on the device and looking at the extra state attributes, where it is shown with the key "deezer_track_id". Tracks do not have a prefix so the ID needs to be used directly. @@ -144,7 +134,7 @@ Deezer track IDs can currently only easily be found by playing the track on the All device triggers can be received by listinging to `bangolufsen_event` event types. -Additionally the "raw" WebSocket notifications received from the device are fired as events in Home Assistant. These can be received by listening to `bangolufsen_websocket_event` event types. +Additionally the "raw" WebSocket notifications received from the device are fired as events in Home Assistant. These can be received by listening to `bangolufsen_websocket_event` event types where `device_id` is used to differentiate devices. ### Physical buttons and sensors diff --git a/custom_components/bangolufsen/__init__.py b/custom_components/bangolufsen/__init__.py index 9ee06c4..0586bf2 100644 --- a/custom_components/bangolufsen/__init__.py +++ b/custom_components/bangolufsen/__init__.py @@ -4,10 +4,12 @@ import logging from mozart_api.mozart_client import MozartClient +from urllib3.exceptions import MaxRetryError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send from .binary_sensor import ( BangOlufsenBinarySensor, @@ -15,32 +17,29 @@ BangOlufsenBinarySensorProximity, ) from .button import BangOlufsenButtonFavourite -from .const import ( - DOMAIN, - HASS_BINARY_SENSORS, - HASS_CONTROLLER, - HASS_COORDINATOR, - HASS_FAVOURITES, - HASS_MEDIA_PLAYER, - HASS_NUMBERS, - HASS_SELECTS, - HASS_SENSORS, - HASS_SWITCHES, - HASS_TEXT, - SUPPORTS_PROXIMITY_SENSOR, -) -from .controller import BangOlufsenController +from .const import DOMAIN, STOP_WEBSOCKET, EntityEnum, ModelEnum, SupportEnum from .coordinator import BangOlufsenCoordinator from .media_player import BangOlufsenMediaPlayer -from .number import BangOlufsenNumberBass, BangOlufsenNumberTreble -from .select import BangOlufsenSelectSoundMode +from .number import BangOlufsenNumber, BangOlufsenNumberBass, BangOlufsenNumberTreble +from .select import ( + BangOlufsenSelect, + BangOlufsenSelectListeningPosition, + BangOlufsenSelectSoundMode, +) from .sensor import ( + BangOlufsenSensor, BangOlufsenSensorBatteryChargingTime, BangOlufsenSensorBatteryLevel, BangOlufsenSensorBatteryPlayingTime, + BangOlufsenSensorInputSignal, + BangOlufsenSensorMediaId, +) +from .switch import BangOlufsenSwitch, BangOlufsenSwitchLoudness +from .text import ( + BangOlufsenText, + BangOlufsenTextFriendlyName, + BangOlufsenTextHomeControlUri, ) -from .switch import BangOlufsenSwitchLoudness -from .text import BangOlufsenTextMediaId PLATFORMS = [ Platform.BINARY_SENSOR, @@ -75,6 +74,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + # Close WebSocket listener(s) and coordinator + async_dispatcher_send(hass, f"{entry.unique_id}_{STOP_WEBSOCKET}") + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -90,17 +92,16 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: async def init_entities(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Initialise the supported entities of the device.""" - client = MozartClient(host=entry.data[CONF_HOST]) supports_battery = False model = entry.data[CONF_MODEL] # Check connection and try to initialize it. - if not ( - battery_state := client.get_battery_state( + try: + battery_state = client.get_battery_state( async_req=True, _request_timeout=3 ).get() - ): + except MaxRetryError: _LOGGER.error("Unable to connect to %s", entry.data[CONF_NAME]) return False @@ -119,17 +120,20 @@ async def init_entities(hass: HomeAssistant, entry: ConfigEntry) -> bool: binary_sensors.append(BangOlufsenBinarySensorBatteryCharging(entry)) # Check if device supports proxmity detection. - if model in SUPPORTS_PROXIMITY_SENSOR: + if model in SupportEnum.PROXIMITY_SENSOR.value: binary_sensors.append(BangOlufsenBinarySensorProximity(entry)) # Create the Number entities. - numbers = [BangOlufsenNumberBass(entry), BangOlufsenNumberTreble(entry)] + numbers: list[BangOlufsenNumber] = [ + BangOlufsenNumberBass(entry), + BangOlufsenNumberTreble(entry), + ] # Get available favourites. favourites = client.get_presets(async_req=True).get() # Create the favourites Button entities. - favourite_buttons = [] + favourite_buttons: list[BangOlufsenButtonFavourite] = [] for favourite_id in favourites: favourite_buttons.append( @@ -137,7 +141,10 @@ async def init_entities(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Create the Sensor entities. - sensors = [] + sensors: list[BangOlufsenSensor] = [ + BangOlufsenSensorInputSignal(entry), + BangOlufsenSensorMediaId(entry), + ] if supports_battery: sensors.extend( @@ -149,37 +156,56 @@ async def init_entities(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Create the Switch entities. - switches = [BangOlufsenSwitchLoudness(entry)] + switches: list[BangOlufsenSwitch] = [BangOlufsenSwitchLoudness(entry)] # Create the Text entities. - texts = [BangOlufsenTextMediaId(entry)] + beolink_self = client.get_beolink_self(async_req=True).get() + + texts: list[BangOlufsenText] = [ + BangOlufsenTextFriendlyName(entry, beolink_self.friendly_name), + ] + + # Add the Home Control URI entity if the device supports it + if model in SupportEnum.HOME_CONTROL.value: + home_control = client.get_remote_home_control_uri(async_req=True).get() + + texts.append(BangOlufsenTextHomeControlUri(entry, home_control.uri)) # Create the Select entities. - selects = [] + selects: list[BangOlufsenSelect] = [] + + # Create the listening position Select entity if supported + scenes = client.get_all_scenes(async_req=True).get() + + # Listening positions + for scene_key in scenes: + scene = scenes[scene_key] + + if scene.tags is not None and "listeningposition" in scene.tags: + selects.append(BangOlufsenSelectListeningPosition(entry)) + break # Create the sound mode select entity if supported - listening_modes = client.get_listening_mode_set(async_req=True).get() - if len(listening_modes) > 0: - selects.append(BangOlufsenSelectSoundMode(entry)) + # Currently the Balance does not expose any useful Sound Modes and should be excluded + if model != ModelEnum.balance: + listening_modes = client.get_listening_mode_set(async_req=True).get() + if len(listening_modes) > 0: + selects.append(BangOlufsenSelectSoundMode(entry)) # Create the Media Player entity. - media_player = BangOlufsenMediaPlayer(entry, coordinator) - - # Handle WebSocket notifications - controller = BangOlufsenController(hass, entry) + media_player = BangOlufsenMediaPlayer(entry) # Add the created entities hass.data[DOMAIN][entry.unique_id] = { - HASS_BINARY_SENSORS: binary_sensors, - HASS_CONTROLLER: controller, - HASS_COORDINATOR: coordinator, - HASS_MEDIA_PLAYER: media_player, - HASS_NUMBERS: numbers, - HASS_FAVOURITES: favourite_buttons, - HASS_SENSORS: sensors, - HASS_SWITCHES: switches, - HASS_SELECTS: selects, - HASS_TEXT: texts, + EntityEnum.BINARY_SENSORS: binary_sensors, + EntityEnum.COORDINATOR: coordinator, + EntityEnum.MEDIA_PLAYER: media_player, + EntityEnum.NUMBERS: numbers, + EntityEnum.FAVOURITES: favourite_buttons, + EntityEnum.SENSORS: sensors, + EntityEnum.SWITCHES: switches, + EntityEnum.SELECTS: selects, + EntityEnum.TEXT: texts, } return True diff --git a/custom_components/bangolufsen/binary_sensor.py b/custom_components/bangolufsen/binary_sensor.py index c8affbf..c3e0327 100644 --- a/custom_components/bangolufsen/binary_sensor.py +++ b/custom_components/bangolufsen/binary_sensor.py @@ -1,4 +1,4 @@ -"""Binary sensor entities for the Bang & Olufsen integration.""" +"""Binary Sensor entities for the Bang & Olufsen integration.""" from __future__ import annotations from mozart_api.models import BatteryState, WebsocketNotificationTag @@ -10,16 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONNECTION_STATUS, - DOMAIN, - HASS_BINARY_SENSORS, - BangOlufsenVariables, - WebSocketNotification, -) +from .const import DOMAIN, BangOlufsenEntity, EntityEnum, WebSocketNotification async def async_setup_entry( @@ -31,43 +24,21 @@ async def async_setup_entry( entities = [] configuration = hass.data[DOMAIN][config_entry.unique_id] - # Add Binary Sensor entities - for binary_sensor in configuration[HASS_BINARY_SENSORS]: + # Add BinarySensor entities + for binary_sensor in configuration[EntityEnum.BINARY_SENSORS]: entities.append(binary_sensor) - async_add_entities(new_entities=entities, update_before_add=True) + async_add_entities(new_entities=entities) -class BangOlufsenBinarySensor(BangOlufsenVariables, BinarySensorEntity): +class BangOlufsenBinarySensor(BangOlufsenEntity, BinarySensorEntity): """Base Binary Sensor class.""" def __init__(self, entry: ConfigEntry) -> None: """Init the Binary Sensor.""" super().__init__(entry) - self._attr_should_poll = False - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) - - async def async_added_to_hass(self) -> None: - """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ) - ] - - async def async_will_remove_from_hass(self) -> None: - """Turn off the dispatchers.""" - for dispatcher in self._dispatchers: - dispatcher() - - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self._attr_available = connection_state - - self.async_write_ha_state() + self._attr_is_on = False class BangOlufsenBinarySensorBatteryCharging(BangOlufsenBinarySensor): @@ -84,25 +55,18 @@ def __init__(self, entry: ConfigEntry) -> None: async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - - self._dispatchers = [ + await super().async_added_to_hass() + self._dispatchers.append( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebSocketNotification.BATTERY}", self._update_battery_charging, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - ] + ) + ) async def _update_battery_charging(self, data: BatteryState) -> None: - """Update binary sensor.""" - self._battery = data - self._attr_is_on = self._battery.is_charging - + """Update battery charging.""" + self._attr_is_on = data.is_charging self.async_write_ha_state() @@ -116,31 +80,23 @@ def __init__(self, entry: ConfigEntry) -> None: self._attr_name = f"{self._name} proximity" self._attr_unique_id = f"{self._unique_id}-proximity" self._attr_icon = "mdi:account-question" - self._attr_device_class = "proximity" - self._attr_is_on = False async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ + await super().async_added_to_hass() + self._dispatchers.append( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebSocketNotification.PROXIMITY}", self._update_proximity, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - ] + ) + ) async def _update_proximity(self, data: WebsocketNotificationTag) -> None: - """Update binary sensor.""" - self._notification = data - - if self._notification.value == "proximityPresenceDetected": + """Update proximity.""" + if data.value == "proximityPresenceDetected": self._attr_is_on = True - elif self._notification.value == "proximityPresenceNotDetected": + elif data.value == "proximityPresenceNotDetected": self._attr_is_on = False self.async_write_ha_state() diff --git a/custom_components/bangolufsen/button.py b/custom_components/bangolufsen/button.py index 459b9d7..42b353c 100644 --- a/custom_components/bangolufsen/button.py +++ b/custom_components/bangolufsen/button.py @@ -1,25 +1,18 @@ """Button entities for the Bang & Olufsen integration.""" +# pylint: disable=too-many-ancestors + + from __future__ import annotations from mozart_api.models import Preset -from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONNECTION_STATUS, - DOMAIN, - HASS_FAVOURITES, - BangOlufsenVariables, - get_device, - generate_favourite_attributes, -) +from .const import DOMAIN, BangOlufsenEntity, EntityEnum, generate_favourite_attributes from .coordinator import BangOlufsenCoordinator @@ -32,45 +25,16 @@ async def async_setup_entry( entities = [] configuration = hass.data[DOMAIN][config_entry.unique_id] - # Add favourite Button entities. - for button in configuration[HASS_FAVOURITES]: + # Add Button entities. + for button in configuration[EntityEnum.FAVOURITES]: entities.append(button) - async_add_entities(new_entities=entities, update_before_add=True) + async_add_entities(new_entities=entities) -class BangOlufsenButton(ButtonEntity, BangOlufsenVariables): +class BangOlufsenButton(ButtonEntity, BangOlufsenEntity): """Base Button class.""" - def __init__(self, entry: ConfigEntry) -> None: - """Init the Button.""" - super().__init__(entry) - - self._attr_entity_category = None - self._attr_device_class = ButtonDeviceClass.UPDATE - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) - - async def async_added_to_hass(self) -> None: - """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ) - ] - - async def async_will_remove_from_hass(self) -> None: - """Turn off the dispatchers.""" - for dispatcher in self._dispatchers: - dispatcher() - - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self._attr_available = connection_state - - self.async_write_ha_state() - class BangOlufsenButtonFavourite(CoordinatorEntity, BangOlufsenButton): """Favourite Button.""" @@ -87,11 +51,9 @@ def __init__( self._favourite_id: int = int(favourite.name[6:]) self._favourite: Preset = favourite - self._device: DeviceEntry | None = get_device(self.hass, self._unique_id) self._attr_name = f"{self._name} Favourite {self._favourite_id}" self._attr_unique_id = f"{self._unique_id}-favourite-{self._favourite_id}" - self._attr_device_class = None if self._favourite_id in range(10): self._attr_icon = f"mdi:numeric-{self._favourite_id}-box" @@ -100,13 +62,7 @@ def __init__( async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self.entry.unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ) - ] + await super().async_added_to_hass() self.async_on_remove( self.coordinator.async_add_listener(self._update_favourite) @@ -126,6 +82,7 @@ def _update_favourite(self) -> None: old_favourite = self._favourite self._favourite = self.coordinator.data["favourites"][str(self._favourite_id)] + # Only update if there is something to update if old_favourite != self._favourite: self._attr_extra_state_attributes = generate_favourite_attributes( self._favourite diff --git a/custom_components/bangolufsen/config_flow.py b/custom_components/bangolufsen/config_flow.py index 3faf0eb..a40441c 100644 --- a/custom_components/bangolufsen/config_flow.py +++ b/custom_components/bangolufsen/config_flow.py @@ -3,7 +3,7 @@ import ipaddress import logging -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from mozart_api.exceptions import ApiException from mozart_api.mozart_client import MozartClient @@ -33,7 +33,6 @@ DEFAULT_HOST, DEFAULT_MAX_VOLUME, DEFAULT_MODEL, - DEFAULT_NAME, DEFAULT_VOLUME_RANGE, DEFAULT_VOLUME_STEP, DOMAIN, @@ -48,14 +47,12 @@ def _config_schema( - name: str = DEFAULT_NAME, volume_step: int = DEFAULT_VOLUME_STEP, default_volume: int = DEFAULT_DEFAULT_VOLUME, max_volume: int = DEFAULT_MAX_VOLUME, ) -> dict: """Create a schema for configuring the device with adjustable default values.""" return { - vol.Optional(CONF_NAME, default=name): cv.string, vol.Required(CONF_VOLUME_STEP, default=volume_step): vol.All( vol.Coerce(int), vol.Range( @@ -80,36 +77,6 @@ def _config_schema( } -async def _validate_host(host: str) -> tuple[str, str, str]: - """Validate that a connection can be made to the device and return jid, friendly name and serial number.""" - try: - # Check if the IP address is a valid address. - ipaddress.ip_address(host) - - # Get information from Beolink self method. - client = MozartClient(host) - - beolink_self = client.get_beolink_self(async_req=True, _request_timeout=3).get() - - beolink_jid = beolink_self.jid - name = beolink_self.friendly_name - serial_number = beolink_self.jid.split(".")[2].split("@")[0] - - return (beolink_jid, name, serial_number) - - except ApiException as error: - raise AbortFlow(reason=API_EXCEPTION) from error - - except NewConnectionError as error: - raise AbortFlow(reason=NEW_CONNECTION_ERROR) from error - - except MaxRetryError as error: - raise AbortFlow(reason=MAX_RETRY_ERROR) from error - - except ValueError as error: - raise AbortFlow(reason=VALUE_ERROR) from error - - class UserInput(TypedDict): """TypedDict for user_input.""" @@ -133,8 +100,38 @@ def __init__(self) -> None: self._serial_number: str = "" self._beolink_jid: str = "" + self._client: MozartClient | None = None + VERSION = 1 + async def _validate_host(self) -> None: + """Validate that a connection can be made to the device and set jid and serial number.""" + try: + # Check if the IP address is a valid address. + ipaddress.ip_address(self._host) + + self._client = MozartClient(self._host) + + # Get information from Beolink self method. + beolink_self = self._client.get_beolink_self( + async_req=True, _request_timeout=3 + ).get() + + self._beolink_jid = beolink_self.jid + self._serial_number = beolink_self.jid.split(".")[2].split("@")[0] + + except ApiException as error: + raise AbortFlow(reason=API_EXCEPTION) from error + + except NewConnectionError as error: + raise AbortFlow(reason=NEW_CONNECTION_ERROR) from error + + except MaxRetryError as error: + raise AbortFlow(reason=MAX_RETRY_ERROR) from error + + except ValueError as error: + raise AbortFlow(reason=VALUE_ERROR) from error + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -142,9 +139,9 @@ async def async_step_user( if user_input is not None: self._host = user_input[CONF_HOST] self._model = user_input[CONF_MODEL] - self._beolink_jid, self._name, self._serial_number = await _validate_host( - self._host - ) + await self._validate_host() + + self._name = f"{self._model}-{self._serial_number}" await self.async_set_unique_id(self._serial_number) self._abort_if_unique_id_configured() @@ -174,9 +171,13 @@ async def async_step_zeroconf( self._model = discovery_info.hostname[:-16].replace("-", " ") self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER] self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com" - self._name = discovery_info.properties[ATTR_FRIENDLY_NAME] + self._name = f"{self._model}-{self._serial_number}" + + self._client = MozartClient(self._host) - self.context["title_placeholders"] = {"name": self._name} + self.context["title_placeholders"] = { + "name": discovery_info.properties[ATTR_FRIENDLY_NAME] + } await self.async_set_unique_id(self._serial_number) self._abort_if_unique_id_configured() @@ -188,24 +189,24 @@ async def async_step_confirm( ) -> FlowResult: """Confirm the configuration of the device.""" if user_input is not None: - self._name = user_input[CONF_NAME] # Make sure that all information is included data = user_input data[CONF_HOST] = self._host data[CONF_MODEL] = self._model data[CONF_BEOLINK_JID] = self._beolink_jid + data[CONF_NAME] = self._name return self.async_create_entry( title=self._name, data=data, ) - client = MozartClient(self._host) - volume_settings = client.get_volume_settings(async_req=True).get() + volume_settings = ( + cast(MozartClient, self._client).get_volume_settings(async_req=True).get() + ) data_schema = _config_schema( - name=self._name, default_volume=volume_settings.default.level, max_volume=volume_settings.maximum.level, ) @@ -242,33 +243,27 @@ async def async_step_init(self, user_input: UserInput | None = None) -> FlowResu if user_input is not None: # Make sure that everything get included in the data. data = user_input - data[CONF_MODEL] = self._config_entry.data[CONF_MODEL] data[CONF_BEOLINK_JID] = self._config_entry.data[CONF_BEOLINK_JID] - if not self.show_advanced_options: - data[CONF_HOST] = self._config_entry.data[CONF_HOST] + data[CONF_HOST] = self._config_entry.data[CONF_HOST] # Check connection - await _validate_host(data[CONF_HOST]) return self.async_create_entry(title=data[CONF_NAME], data=data) # Create data schema with the last configuration as default values. - data_schema = _config_schema( - name=self._config_entry.data[CONF_NAME], - volume_step=self._config_entry.data[CONF_VOLUME_STEP], - default_volume=self._config_entry.data[CONF_DEFAULT_VOLUME], - max_volume=self._config_entry.data[CONF_MAX_VOLUME], - ) - - # Only show the ip address if advanced options are on. - if self.show_advanced_options: - data_schema.update( - { - vol.Required( - CONF_HOST, default=self._config_entry.data[CONF_HOST] - ): cv.string - }, + # Also add the ability to change the friendly name in Home Assistant + data_schema = { + vol.Optional( + CONF_NAME, default=self._config_entry.data[CONF_NAME] + ): cv.string, + } + data_schema.update( + _config_schema( + volume_step=self._config_entry.data[CONF_VOLUME_STEP], + default_volume=self._config_entry.data[CONF_DEFAULT_VOLUME], + max_volume=self._config_entry.data[CONF_MAX_VOLUME], ) + ) # Create options form with selected options. return self.async_show_form( diff --git a/custom_components/bangolufsen/const.py b/custom_components/bangolufsen/const.py index dbe1017..6acfe25 100644 --- a/custom_components/bangolufsen/const.py +++ b/custom_components/bangolufsen/const.py @@ -1,33 +1,31 @@ """Constants for the Bang & Olufsen integration.""" +# pylint: disable=invalid-name too-many-instance-attributes too-few-public-methods + from __future__ import annotations from enum import Enum from typing import Final, cast from mozart_api.models import ( - AlarmTriggeredInfo, BatteryState, - BeolinkExperience, - BeolinkExperiencesResult, - BeolinkJoinResult, BeoRemoteButton, ButtonEvent, + ListeningModeProps, PlaybackContentMetadata, PlaybackError, PlaybackProgress, PowerStateEnum, Preset, - ProductCurtainStatus, RenderingState, SoftwareUpdateState, SoundSettings, Source, SourceArray, SourceTypeEnum, - SpeakerRoleEnum, - VolumeState, + SpeakerGroupOverview, VolumeLevel, VolumeMute, + VolumeState, WebsocketNotificationTag, ) from mozart_api.mozart_client import MozartClient @@ -39,6 +37,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity class ArtSizeEnum(Enum): @@ -69,6 +69,7 @@ class SourceEnum(StrEnum): tv = "TV" deezer = "Deezer" beolink = "Networklink" + tidalConnect = "Tidal Connect" class RepeatEnum(StrEnum): @@ -114,15 +115,88 @@ class ProximityEnum(Enum): proximityPresenceNotDetected = False +class ModelEnum(StrEnum): + """Enum for compatible model names.""" + + beolab_28 = "BeoLab 28" + balance = "Beosound Balance" + emerge = "Beosound Emerge" + level = "Beosound Level" + theatre = "Beosound Theatre" + + +class EntityEnum(StrEnum): + """Enum for accessing and storing the entities in hass.""" + + BINARY_SENSORS = "binary_sensors" + COORDINATOR = "coordinator" + MEDIA_PLAYER = "media_player" + NUMBERS = "numbers" + FAVOURITES = "favourites" + SENSORS = "sensors" + SWITCHES = "switches" + TEXT = "text" + SELECTS = "selects" + + +# Dispatcher events +class WebSocketNotification(StrEnum): + """Enum for WebSocket notification types.""" + + ACTIVE_LISTENING_MODE: Final[str] = "active_listening_mode" + ACTIVE_SPEAKER_GROUP: Final[str] = "active_speaker_group" + ALARM_TRIGGERED: Final[str] = "alarm_triggered" + BATTERY: Final[str] = "battery" + BEOLINK_EXPERIENCES_RESULT: Final[str] = "beolink_experiences_result" + BEOLINK_JOIN_RESULT: Final[str] = "beolink_join_result" + BEO_REMOTE_BUTTON: Final[str] = "beo_remote_button" + BUTTON: Final[str] = "button" + CURTAINS: Final[str] = "curtains" + PLAYBACK_ERROR: Final[str] = "playback_error" + PLAYBACK_METADATA: Final[str] = "playback_metadata" + PLAYBACK_PROGRESS: Final[str] = "playback_progress" + PLAYBACK_SOURCE: Final[str] = "playback_source" + PLAYBACK_STATE: Final[str] = "playback_state" + POWER_STATE: Final[str] = "power_state" + ROLE: Final[str] = "role" + SOFTWARE_UPDATE_STATE: Final[str] = "software_update_state" + SOUND_SETTINGS: Final[str] = "sound_settings" + SOURCE_CHANGE: Final[str] = "source_change" + VOLUME: Final[str] = "volume" + + # Sub-notifications + NOTIFICATION: Final[str] = "notification" + PROXIMITY: Final[str] = "proximity" + BEOLINK: Final[str] = "beolink" + REMOTE_MENU_CHANGED: Final[str] = "remoteMenuChanged" + CONFIGURATION: Final[str] = "configuration" + BLUETOOTH_DEVICES: Final[str] = "bluetooth" + REMOTE_CONTROL_DEVICES: Final[str] = "remoteControlDevices" + + ALL: Final[str] = "all" + + +class SupportEnum(Enum): + """Enum for storing compatibility of devices.""" + + PROXIMITY_SENSOR = ( + ModelEnum.beolab_28, + ModelEnum.balance, + ModelEnum.level, + ModelEnum.theatre, + ) + + HOME_CONTROL = (ModelEnum.theatre,) + + DOMAIN: Final[str] = "bangolufsen" # Default values for configuration. -DEFAULT_NAME: Final[str] = "Bang & Olufsen device" DEFAULT_HOST: Final[str] = "192.168.1.1" DEFAULT_DEFAULT_VOLUME: Final[int] = 40 DEFAULT_MAX_VOLUME: Final[int] = 100 DEFAULT_VOLUME_STEP: Final[int] = 5 -DEFAULT_MODEL: Final[str] = "Beosound Balance" +DEFAULT_MODEL: Final[str] = ModelEnum.balance # Acceptable ranges for configuration. DEFAULT_VOLUME_RANGE: Final[range] = range(1, (70 + 1), 1) @@ -145,26 +219,7 @@ class ProximityEnum(Enum): # Models to choose from in manual configuration. -COMPATIBLE_MODELS: list[str] = [ - "BeoLab 28", - "Beosound Balance", - "Beosound Emerge", - "Beosound Level", - "Beosound Theatre", -] - - -# Constants for accessing and storing the entities in hass. -HASS_BINARY_SENSORS: Final = "binary_sensors" -HASS_CONTROLLER: Final = "controller" -HASS_COORDINATOR: Final = "coordinator" -HASS_MEDIA_PLAYER: Final = "media_player" -HASS_NUMBERS: Final = "numbers" -HASS_FAVOURITES: Final = "favourites" -HASS_SENSORS: Final = "sensors" -HASS_SWITCHES: Final = "switches" -HASS_TEXT: Final = "text" -HASS_SELECTS: Final = "selects" +COMPATIBLE_MODELS: list[str] = [x.value for x in ModelEnum] # Attribute names for zeroconf discovery. @@ -200,6 +255,8 @@ class ProximityEnum(Enum): "wpl", "pl", "beolink", + "classicsAdapter", + "usbIn", ) # Fallback sources to use in case of API failure. @@ -208,14 +265,21 @@ class ProximityEnum(Enum): Source( id="uriStreamer", is_enabled=True, - is_playable=True, + is_playable=False, name="Audio Streamer", type=SourceTypeEnum("uriStreamer"), ), + Source( + id="bluetooth", + is_enabled=True, + is_playable=False, + name="Bluetooth", + type=SourceTypeEnum("bluetooth"), + ), Source( id="spotify", is_enabled=True, - is_playable=True, + is_playable=False, name="Spotify Connect", type=SourceTypeEnum("spotify"), ), @@ -247,60 +311,29 @@ class ProximityEnum(Enum): name="Deezer", type=SourceTypeEnum("deezer"), ), + Source( + id="tidalConnect", + is_enabled=True, + is_playable=True, + name="Tidal Connect", + type=SourceTypeEnum("tidalConnect"), + ), ] ) -# Product capabilities for creating entities -SUPPORTS_PROXIMITY_SENSOR: Final[tuple] = ( - "BeoLab 28", - "Beosound Balance", - "Beosound Level", - "Beosound Theatre", -) - # Device trigger events BANGOLUFSEN_EVENT: Final[str] = f"{DOMAIN}_event" BANGOLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event" -# Device dispatcher events -CLEANUP: Final[str] = "CLEANUP" - -# Dispatcher events -class WebSocketNotification(StrEnum): - """Enum for WebSocket notification types.""" - - ACTIVE_LISTENING_MODE: Final[str] = "active_listening_mode" - ALARM_TRIGGERED: Final[str] = "alarm_triggered" - BATTERY: Final[str] = "battery" - BEOLINK_EXPERIENCES_RESULT: Final[str] = "beolink_experiences_result" - BEOLINK_JOIN_RESULT: Final[str] = "beolink_join_result" - BEO_REMOTE_BUTTON: Final[str] = "beo_remote_button" - BUTTON: Final[str] = "button" - CURTAINS: Final[str] = "curtains" - NOTIFICATION: Final[str] = "notification" - PROXIMITY: Final[str] = "proximity" - PLAYBACK_ERROR: Final[str] = "playback_error" - PLAYBACK_METADATA: Final[str] = "playback_metadata" - PLAYBACK_PROGRESS: Final[str] = "playback_progress" - PLAYBACK_SOURCE: Final[str] = "playback_source" - PLAYBACK_STATE: Final[str] = "playback_state" - POWER_STATE: Final[str] = "power_state" - ROLE: Final[str] = "role" - SOFTWARE_UPDATE_STATE: Final[str] = "software_update_state" - SOUND_SETTINGS: Final[str] = "sound_settings" - SOURCE_CHANGE: Final[str] = "source_change" - VOLUME: Final[str] = "volume" - ALL: Final[str] = "all" - - -WS_REMOTE_CONTROL_AVAILABLE: Final[str] = "WEBSOCKET_REMOTE_CONTROL_CHECK" CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS" +START_WEBSOCKET: Final[str] = "START_WEBSOCKET" +STOP_WEBSOCKET: Final[str] = "STOP_WEBSOCKET" BEOLINK_LEADER_COMMAND: Final[str] = "BEOLINK_LEADER_COMMAND" BEOLINK_LISTENER_COMMAND: Final[str] = "BEOLINK_LISTENER_COMMAND" BEOLINK_VOLUME: Final[str] = "BEOLINK_VOLUME" -MEDIA_ID: Final[str] = "MEDIA_ID" + # Misc. NO_METADATA: Final[tuple] = (None, "", 0) @@ -411,7 +444,7 @@ def generate_favourite_attributes( class BangOlufsenVariables: - """Shared variables for entities.""" + """Shared variables for various classes.""" def __init__(self, entry: ConfigEntry) -> None: """Initialize the object.""" @@ -424,40 +457,18 @@ def __init__(self, entry: ConfigEntry) -> None: self._name: str = self.entry.data[CONF_NAME] self._unique_id: str = cast(str, self.entry.unique_id) - self._dispatchers: list = [] - self._client: MozartClient = MozartClient( host=self._host, websocket_reconnect=True ) # Objects that get directly updated by notifications. - self._alarm_triggered: AlarmTriggeredInfo = AlarmTriggeredInfo() + self._active_listening_mode = ListeningModeProps() + self._active_speaker_group = SpeakerGroupOverview( + friendly_name="", id="", is_deleteable=False + ) self._battery: BatteryState = BatteryState() self._beo_remote_button: BeoRemoteButton = BeoRemoteButton() - self._beolink_experiences_result: BeolinkExperiencesResult = ( - BeolinkExperiencesResult( - experiences=[ - BeolinkExperience( - category="UNKNOWN", - id="", - linkable=False, - name="", - type="", - unique_source_id="", - ) - ], - request_id="", - status="ok", - ) - ) - self._beolink_join_result: BeolinkJoinResult = BeolinkJoinResult( - jid="", - request_id="", - status="idle", - type="join", - ) self._button: ButtonEvent = ButtonEvent() - self._curtains: ProductCurtainStatus = ProductCurtainStatus() self._notification: WebsocketNotificationTag = WebsocketNotificationTag() self._playback_error: PlaybackError = PlaybackError() self._playback_metadata: PlaybackContentMetadata = PlaybackContentMetadata() @@ -465,10 +476,44 @@ def __init__(self, entry: ConfigEntry) -> None: self._playback_source: Source = Source() self._playback_state: RenderingState = RenderingState() self._power_state: PowerStateEnum = PowerStateEnum() - self._role: SpeakerRoleEnum = SpeakerRoleEnum() self._software_update_state: SoftwareUpdateState = SoftwareUpdateState() self._sound_settings: SoundSettings = SoundSettings() self._source_change: Source = Source() self._volume: VolumeState = VolumeState( level=VolumeLevel(level=0), muted=VolumeMute(muted=False) ) + + +class BangOlufsenEntity(Entity, BangOlufsenVariables): + """Base Entity for BangOlufsen entities.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the object.""" + BangOlufsenVariables.__init__(self, entry) + self._dispatchers: list = [] + + self._attr_should_poll = False + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) + self._attr_device_class = None + self._attr_entity_category = None + + async def async_added_to_hass(self) -> None: + """Turn on the dispatchers.""" + self._dispatchers = [ + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{CONNECTION_STATUS}", + self._update_connection_state, + ) + ] + + async def async_will_remove_from_hass(self) -> None: + """Turn off the dispatchers.""" + for dispatcher in self._dispatchers: + dispatcher() + + async def _update_connection_state(self, connection_state: bool) -> None: + """Update entity connection state.""" + self._attr_available = connection_state + + self.async_write_ha_state() diff --git a/custom_components/bangolufsen/controller.py b/custom_components/bangolufsen/controller.py deleted file mode 100644 index 59a5a83..0000000 --- a/custom_components/bangolufsen/controller.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Websocket listener handling for the Bang & Olufsen integration.""" -from __future__ import annotations - -import asyncio -import logging - -from mozart_api.models import ( - BatteryState, - BeoRemoteButton, - ButtonEvent, - ListeningModeProps, - PlaybackContentMetadata, - PlaybackError, - PlaybackProgress, - RenderingState, - SoundSettings, - Source, - VolumeState, - WebsocketNotificationTag, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) - -from .const import ( - BANGOLUFSEN_EVENT, - BANGOLUFSEN_WEBSOCKET_EVENT, - CLEANUP, - CONNECTION_STATUS, - MEDIA_ID, - WS_REMOTE_CONTROL_AVAILABLE, - BangOlufsenVariables, - SourceEnum, - WebSocketNotification, - get_device, -) - -_LOGGER = logging.getLogger(__name__) - - -class BangOlufsenController(BangOlufsenVariables): - """The dispatcher and handler for WebSocket notifications.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Init the dispatcher and handler for WebSocket notifications.""" - super().__init__(entry) - - self.hass = hass - self.websocket_remote_control: bool = False - - # Get the device for device triggers - self._device: DeviceEntry | None = get_device(self.hass, self._unique_id) - - self._client.get_on_connection(self.on_connection) - self._client.get_on_connection_lost(self.on_connection_lost) - 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 - ) - self._client.get_button_notifications(self.on_button_notification) - self._client.get_notification_notifications(self.on_notification_notification) - self._client.get_playback_error_notifications( - self.on_playback_error_notification - ) - self._client.get_playback_metadata_notifications( - self.on_playback_metadata_notification - ) - self._client.get_playback_progress_notifications( - self.on_playback_progress_notification - ) - self._client.get_playback_state_notifications( - self.on_playback_state_notification - ) - self._client.get_sound_settings_notifications( - self.on_sound_settings_notification - ) - self._client.get_source_change_notifications(self.on_source_change_notification) - self._client.get_volume_notifications(self.on_volume_notification) - - # Used for firing events and debugging - self._client.get_all_notifications_raw(self.on_all_notifications_raw) - - # Register dispatchers. - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CLEANUP}", - self._disconnect, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WS_REMOTE_CONTROL_AVAILABLE}", - self.start_notification_listener, - ), - ] - - async def start_notification_listener(self) -> bool: - """Start the notification WebSocket listener.""" - - # Kill notification listeners if already running - if self._client.websocket_connected: - self._client.disconnect_notifications() - - # Check if the remote control listener should be activated. - bluetooth_remote_list = self._client.get_bluetooth_remotes(async_req=True).get() - - self.websocket_remote_control = bool(len(bluetooth_remote_list.items)) - - status = await self._async_receive_notifications() - return status - - async def _disconnect(self) -> None: - """Terminate the WebSocket connection(s) and remove dispatchers.""" - await self._wait_for_disconnect() - self._update_connection_status() - - for dispatcher in self._dispatchers: - dispatcher() - - def _update_connection_status(self) -> None: - """Update all entities of the connection status.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._client.websocket_connected, - ) - - async def _wait_for_connection(self) -> None: - """Wait for WebSocket connection to be established.""" - self._client.connect_notifications(self.websocket_remote_control) - - while not self._client.websocket_connected: - pass - - async def _wait_for_disconnect(self) -> None: - """Wait for WebSocket connection to be disconnected.""" - self._client.disconnect_notifications() - - while self._client.websocket_connected: - pass - - async def _async_receive_notifications(self) -> bool: - """Receive all WebSocket notifications.""" - try: - await asyncio.wait_for(self._wait_for_connection(), timeout=10.0) - except asyncio.TimeoutError: - _LOGGER.error("Unable to connect to the WebSocket notification channel") - return False - return True - - def on_connection(self) -> None: - """Handle WebSocket connection made.""" - _LOGGER.info("Connected to the %s notification channel", self._name) - self._update_connection_status() - - def on_connection_lost(self) -> None: - """Handle WebSocket connection lost.""" - _LOGGER.error("Lost connection to the %s", self._name) - self._update_connection_status() - - def on_battery_notification(self, notification: BatteryState) -> None: - """Send battery dispatch.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.BATTERY}", - notification, - ) - - def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None: - """Send beo_remote_button dispatch.""" - if not isinstance(self._device, DeviceEntry): - self._device = get_device(self.hass, self._unique_id) - - assert isinstance(self._device, DeviceEntry) - - if notification.type == "KeyPress": - self.hass.bus.async_fire( - BANGOLUFSEN_EVENT, - event_data={ - CONF_TYPE: f"{notification.key}_{notification.type}", - CONF_DEVICE_ID: self._device.id, - }, - ) - - def on_button_notification(self, notification: ButtonEvent) -> None: - """Send button dispatch.""" - if not isinstance(self._device, DeviceEntry): - self._device = get_device(self.hass, self._unique_id) - - assert isinstance(self._device, DeviceEntry) - - # Trigger the device trigger - self.hass.bus.async_fire( - BANGOLUFSEN_EVENT, - event_data={ - CONF_TYPE: f"{notification.button}_{notification.state}", - CONF_DEVICE_ID: self._device.id, - }, - ) - - def on_notification_notification( - self, notification: WebsocketNotificationTag - ) -> None: - """Send notification dispatch.""" - - if "proximity" in notification.value: - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PROXIMITY}", - notification, - ) - else: - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.NOTIFICATION}", - notification, - ) - - def on_playback_error_notification(self, notification: PlaybackError) -> None: - """Send playback_error dispatch.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PLAYBACK_ERROR}", - notification, - ) - - def on_playback_metadata_notification( - self, notification: PlaybackContentMetadata - ) -> None: - """Send playback_metadata dispatch.""" - - # Send MEDIA_ID dispatch if media_id is available for the source - if notification.source in (SourceEnum.deezer.name, SourceEnum.netRadio.name): - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{MEDIA_ID}", - notification.source_internal_id, - ) - else: - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{MEDIA_ID}", - "None", - ) - - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PLAYBACK_METADATA}", - notification, - ) - - def on_playback_progress_notification(self, notification: PlaybackProgress) -> None: - """Send playback_progress dispatch.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PLAYBACK_PROGRESS}", - notification, - ) - - def on_playback_state_notification(self, notification: RenderingState) -> None: - """Send playback_state dispatch.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PLAYBACK_STATE}", - notification, - ) - - def on_sound_settings_notification(self, notification: SoundSettings) -> None: - """Send sound_settings dispatch.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.SOUND_SETTINGS}", - notification, - ) - - def on_source_change_notification(self, notification: Source) -> None: - """Send source_change dispatch.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.SOURCE_CHANGE}", - notification, - ) - - def on_volume_notification(self, notification: VolumeState) -> None: - """Send volume dispatch.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.VOLUME}", - notification, - ) - - def on_active_listening_mode(self, notification: ListeningModeProps) -> None: - """Send active_listening_mode dispatch.""" - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WebSocketNotification.ACTIVE_LISTENING_MODE}", - notification, - ) - - def on_all_notifications_raw(self, notification: dict) -> None: - """Receive all notifications.""" - _LOGGER.debug("%s : %s", self._name, notification) - self.hass.bus.async_fire(BANGOLUFSEN_WEBSOCKET_EVENT, notification) diff --git a/custom_components/bangolufsen/coordinator.py b/custom_components/bangolufsen/coordinator.py index d586768..61f670f 100644 --- a/custom_components/bangolufsen/coordinator.py +++ b/custom_components/bangolufsen/coordinator.py @@ -1,4 +1,6 @@ -"""Update coordinator for the Bang & Olufsen integration.""" +"""Update coordinator and WebSocket listener(s) for the Bang & Olufsen integration.""" +# pylint: disable=unused-argument raise-missing-from + from __future__ import annotations from datetime import timedelta @@ -6,20 +8,45 @@ from typing import TypedDict from mozart_api.exceptions import ApiException -from mozart_api.models import PlayQueueSettings, Preset -from mozart_api.mozart_client import MozartClient +from mozart_api.models import ( + BatteryState, + BeoRemoteButton, + ButtonEvent, + ListeningModeProps, + PlaybackContentMetadata, + PlaybackError, + PlaybackProgress, + Preset, + RenderingState, + SoftwareUpdateState, + SoundSettings, + Source, + SpeakerGroupOverview, + VolumeState, + WebsocketNotificationTag, +) from urllib3.exceptions import MaxRetryError, NewConnectionError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CLEANUP, CONNECTION_STATUS +from .const import ( + BANGOLUFSEN_EVENT, + BANGOLUFSEN_WEBSOCKET_EVENT, + CONNECTION_STATUS, + START_WEBSOCKET, + BangOlufsenVariables, + WebSocketNotification, + get_device, +) _LOGGER = logging.getLogger(__name__) @@ -28,55 +55,81 @@ class CoordinatorData(TypedDict): """TypedDict for coordinator data.""" favourites: dict[str, Preset] - queue_settings: PlayQueueSettings -class BangOlufsenCoordinator(DataUpdateCoordinator): - """The entity coordinator.""" +class BangOlufsenCoordinator(DataUpdateCoordinator, BangOlufsenVariables): + """The entity coordinator and WebSocket listener(s).""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the entity coordinator.""" - super().__init__( + DataUpdateCoordinator.__init__( + self, hass, _LOGGER, name="coordinator", - update_interval=timedelta(seconds=30), + update_interval=timedelta(seconds=60), ) + BangOlufsenVariables.__init__(self, entry) - self.entry = entry - self._client = MozartClient(host=self.entry.data[CONF_HOST]) - self._dispatchers = [] + self._coordinator_data: CoordinatorData = {"favourites": {}} - self._coordinator_data: CoordinatorData = { - "favourites": {}, - "queue_settings": PlayQueueSettings(), - } + self._device: DeviceEntry | None = None self._dispatchers = [ async_dispatcher_connect( self.hass, - f"{self.entry.unique_id}_{CLEANUP}", - self._disconnect, - ), - async_dispatcher_connect( - self.hass, - f"{self.entry.unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), + f"{self._unique_id}_{START_WEBSOCKET}", + self.connect_websocket, + ) ] - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self.last_update_success = connection_state + # WebSocket callbacks + self._client.get_on_connection(self.on_connection) + self._client.get_on_connection_lost(self.on_connection_lost) + self._client.get_active_listening_mode_notifications( + self.on_active_listening_mode + ) + self._client.get_active_speaker_group_notifications( + self.on_active_speaker_group + ) + self._client.get_battery_notifications(self.on_battery_notification) + self._client.get_beo_remote_button_notifications( + self.on_beo_remote_button_notification + ) + self._client.get_button_notifications(self.on_button_notification) + self._client.get_notification_notifications(self.on_notification_notification) + self._client.get_playback_error_notifications( + self.on_playback_error_notification + ) + self._client.get_playback_metadata_notifications( + self.on_playback_metadata_notification + ) + self._client.get_playback_progress_notifications( + self.on_playback_progress_notification + ) + self._client.get_playback_state_notifications( + self.on_playback_state_notification + ) + self._client.get_sound_settings_notifications( + self.on_sound_settings_notification + ) + self._client.get_source_change_notifications(self.on_source_change_notification) + self._client.get_volume_notifications(self.on_volume_notification) + self._client.get_software_update_state_notifications( + self.on_software_update_state + ) - async def _disconnect(self) -> None: - """Remove dispatchers.""" - for dispatcher in self._dispatchers: - dispatcher() + # Used for firing events and debugging + self._client.get_all_notifications_raw(self.on_all_notifications_raw) + + async def _update_variables(self) -> None: + """Update the coordinator data.""" + favourites = self._client.get_presets(async_req=True, _request_timeout=5).get() + + self._coordinator_data = {"favourites": favourites} async def _async_update_data(self) -> CoordinatorData: """Get all information needed by the polling entities.""" - # Wait for the WebSocket listener to regain connection. if not self.last_update_success: raise UpdateFailed @@ -89,27 +142,228 @@ async def _async_update_data(self) -> CoordinatorData: MaxRetryError, NewConnectionError, ApiException, - Exception, ConnectionResetError, - ) as error: - _LOGGER.error(error) + ): + raise UpdateFailed + + def connect_websocket(self) -> None: + """Start the notification WebSocket listeners.""" + self._client.connect_notifications(remote_control=True) + + def disconnect(self) -> None: + """Terminate the WebSocket connections and remove dispatchers.""" + self._client.disconnect_notifications() + self._update_connection_status() + + def _update_connection_status(self) -> None: + """Update all entities of the connection status.""" + self.last_update_success = self._client.websocket_connected + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{CONNECTION_STATUS}", + self._client.websocket_connected, + ) + + def on_connection(self) -> None: + """Handle WebSocket connection made.""" + _LOGGER.info("Connected to the %s notification channel", self._name) + self._update_connection_status() + + def on_connection_lost(self) -> None: + """Handle WebSocket connection lost.""" + _LOGGER.error("Lost connection to the %s", self._name) + self._update_connection_status() + + # raise UpdateFailed + + def on_active_listening_mode(self, notification: ListeningModeProps) -> None: + """Send active_listening_mode dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.ACTIVE_LISTENING_MODE}", + notification, + ) + + def on_active_speaker_group(self, notification: SpeakerGroupOverview) -> None: + """Send active_speaker_group dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.ACTIVE_SPEAKER_GROUP}", + notification, + ) + + def on_battery_notification(self, notification: BatteryState) -> None: + """Send battery dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.BATTERY}", + notification, + ) + + def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None: + """Send beo_remote_button dispatch.""" + if not isinstance(self._device, DeviceEntry): + self._device = get_device(self.hass, self._unique_id) + + assert isinstance(self._device, DeviceEntry) + + if notification.type == "KeyPress": + # Trigger the device trigger + self.hass.bus.async_fire( + BANGOLUFSEN_EVENT, + event_data={ + CONF_TYPE: f"{notification.key}_{notification.type}", + CONF_DEVICE_ID: self._device.id, + }, + ) + + def on_button_notification(self, notification: ButtonEvent) -> None: + """Send button dispatch.""" + if not isinstance(self._device, DeviceEntry): + self._device = get_device(self.hass, self._unique_id) + + assert isinstance(self._device, DeviceEntry) + + # Trigger the device trigger + self.hass.bus.async_fire( + BANGOLUFSEN_EVENT, + event_data={ + CONF_TYPE: f"{notification.button}_{notification.state}", + CONF_DEVICE_ID: self._device.id, + }, + ) + + def on_notification_notification( + self, notification: WebsocketNotificationTag + ) -> None: + """Send notification dispatch.""" + + if WebSocketNotification.PROXIMITY in notification.value: + async_dispatcher_send( self.hass, - f"{self.entry.unique_id}_{CONNECTION_STATUS}", - False, + f"{self._unique_id}_{WebSocketNotification.PROXIMITY}", + notification, ) - raise UpdateFailed(error) from error - async def _update_variables(self) -> None: - """Update the coordinator data.""" + elif WebSocketNotification.REMOTE_MENU_CHANGED in notification.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.REMOTE_MENU_CHANGED}", + ) - favourites = self._client.get_presets(async_req=True, _request_timeout=5).get() + elif WebSocketNotification.CONFIGURATION in notification.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.CONFIGURATION}", + notification, + ) + + elif WebSocketNotification.BLUETOOTH_DEVICES in notification.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.BLUETOOTH_DEVICES}", + ) + + elif WebSocketNotification.REMOTE_CONTROL_DEVICES in notification.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.BLUETOOTH_DEVICES}", + ) + + elif WebSocketNotification.BEOLINK in notification.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.BEOLINK}", + ) + + def on_playback_error_notification(self, notification: PlaybackError) -> None: + """Send playback_error dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_ERROR}", + notification, + ) + + def on_playback_metadata_notification( + self, notification: PlaybackContentMetadata + ) -> None: + """Send playback_metadata dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_METADATA}", + notification, + ) + + def on_playback_progress_notification(self, notification: PlaybackProgress) -> None: + """Send playback_progress dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_PROGRESS}", + notification, + ) + + def on_playback_state_notification(self, notification: RenderingState) -> None: + """Send playback_state dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_STATE}", + notification, + ) + + def on_sound_settings_notification(self, notification: SoundSettings) -> None: + """Send sound_settings dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.SOUND_SETTINGS}", + notification, + ) + + def on_source_change_notification(self, notification: Source) -> None: + """Send source_change dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.SOURCE_CHANGE}", + notification, + ) + + def on_volume_notification(self, notification: VolumeState) -> None: + """Send volume dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebSocketNotification.VOLUME}", + notification, + ) + + def on_software_update_state(self, notification: SoftwareUpdateState) -> None: + """Check device sw version.""" + + # Get software version. + software_status = self._client.get_softwareupdate_status(async_req=True).get() + + # Update the HA device if the sw version does not match + if not isinstance(self._device, DeviceEntry): + self._device = get_device(self.hass, self._unique_id) + + assert isinstance(self._device, DeviceEntry) + + if software_status.software_version != self._device.sw_version: + device_registry = dr.async_get(self.hass) + + device_registry.async_update_device( + device_id=self._device.id, + sw_version=software_status.software_version, + ) + + def on_all_notifications_raw(self, notification: dict) -> None: + """Receive all notifications.""" + if not isinstance(self._device, DeviceEntry): + self._device = get_device(self.hass, self._unique_id) + + assert isinstance(self._device, DeviceEntry) - queue_settings = self._client.get_settings_queue( - async_req=True, _request_timeout=5 - ).get() + # Add the device_id to the notification + notification["device_id"] = self._device.id - self._coordinator_data = { - "favourites": favourites, - "queue_settings": queue_settings, - } + _LOGGER.debug("%s", notification) + self.hass.bus.async_fire(BANGOLUFSEN_WEBSOCKET_EVENT, notification) diff --git a/custom_components/bangolufsen/device_trigger.py b/custom_components/bangolufsen/device_trigger.py index a3b1c66..3130386 100644 --- a/custom_components/bangolufsen/device_trigger.py +++ b/custom_components/bangolufsen/device_trigger.py @@ -1,19 +1,30 @@ """Device triggers for the Bang & Olufsen integration.""" from __future__ import annotations +import logging from typing import Any +from mozart_api.mozart_client import MozartClient import voluptuous as vol from homeassistant.components.automation import TriggerActionType, TriggerInfo from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import event as event_trigger -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_HOST, + CONF_PLATFORM, + CONF_TYPE, +) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import device_registry from homeassistant.helpers.typing import ConfigType -from .const import BANGOLUFSEN_EVENT, DOMAIN, HASS_CONTROLLER +from .const import BANGOLUFSEN_EVENT, DOMAIN, EntityEnum +from .media_player import BangOlufsenMediaPlayer + +_LOGGER = logging.getLogger(__name__) BUTTON_TRIGGERS = ( "Preset1_shortPress", @@ -29,18 +40,7 @@ "Bluetooth_longPress", ) -ALL_TRIGGERS = ( - "Preset1_shortPress", - "Preset2_shortPress", - "Preset3_shortPress", - "Preset4_shortPress", - "PlayPause_shortPress", - "PlayPause_longPress", - "Next_shortPress", - "Previous_shortPress", - "Microphone_shortPress", - "Bluetooth_shortPress", - "Bluetooth_longPress", +REMOTE_TRIGGERS = ( "Control/Wind_KeyPress", "Control/Rewind_KeyPress", "Control/Play_KeyPress", @@ -137,7 +137,7 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_TYPE): vol.In(ALL_TRIGGERS), + vol.Required(CONF_TYPE): vol.In(REMOTE_TRIGGERS + BUTTON_TRIGGERS), } ) @@ -148,21 +148,23 @@ async def async_get_triggers( """List device triggers for Bang & Olufsen devices.""" triggers = [] - # Get the device serial number in order to retrieve the controller - # and determine if a remote is connected and the device triggers for the remote should be available. - + # Get the host IP address registry = device_registry.async_get(hass) - serial_number = list(registry.devices[device_id].identifiers)[0][1] + media_player: BangOlufsenMediaPlayer = hass.data[DOMAIN][serial_number][ + EntityEnum.MEDIA_PLAYER + ] - controller = hass.data[DOMAIN][serial_number][HASS_CONTROLLER] + client = MozartClient(host=media_player.entry.data[CONF_HOST]) - trigger_types: tuple[str, ...] = () + # Get if a remote control is connected + bluetooth_remote_list = client.get_bluetooth_remotes(async_req=True).get() + remote_control_available = bool(len(bluetooth_remote_list.items)) - if controller.websocket_remote_control: - trigger_types = ALL_TRIGGERS - else: - trigger_types = BUTTON_TRIGGERS + trigger_types: list[str] = list(BUTTON_TRIGGERS) + + if remote_control_available: + trigger_types.extend(REMOTE_TRIGGERS) for trigger_type in trigger_types: @@ -184,6 +186,7 @@ async def async_attach_trigger( automation_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" + event_config = event_trigger.TRIGGER_SCHEMA( { event_trigger.CONF_PLATFORM: "event", diff --git a/custom_components/bangolufsen/manifest.json b/custom_components/bangolufsen/manifest.json index 1974368..f7dffe6 100644 --- a/custom_components/bangolufsen/manifest.json +++ b/custom_components/bangolufsen/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://github.com/bang-olufsen/bangolufsen-hacs", "iot_class": "local_push", "issue_tracker": "https://github.com/bang-olufsen/bangolufsen-hacs/issues", - "requirements": ["mozart-api==2.3.4.15123.6"], - "version": "0.7.0", + "requirements": ["mozart-api==2.5.3.123.0"], + "version": "1.0.0", "zeroconf": ["_bangolufsen._tcp.local."] } diff --git a/custom_components/bangolufsen/media_player.py b/custom_components/bangolufsen/media_player.py index a094f06..583540b 100644 --- a/custom_components/bangolufsen/media_player.py +++ b/custom_components/bangolufsen/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import datetime +from datetime import datetime, timedelta import json import logging from typing import Any, cast @@ -32,7 +32,6 @@ VolumeMute, VolumeSettings, VolumeState, - WebsocketNotificationTag, ) from mozart_api.mozart_client import check_valid_jid import voluptuous as vol @@ -51,22 +50,18 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_platform, - entity_registry as er, -) -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.entity_registry import RegistryEntry -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .const import ( @@ -75,29 +70,25 @@ BEOLINK_LEADER_COMMAND, BEOLINK_LISTENER_COMMAND, BEOLINK_VOLUME, - CLEANUP, CONF_BEOLINK_JID, CONF_DEFAULT_VOLUME, CONF_MAX_VOLUME, CONF_VOLUME_STEP, - CONNECTION_STATUS, DOMAIN, FALLBACK_SOURCES, - HASS_CONTROLLER, - HASS_MEDIA_PLAYER, HIDDEN_SOURCE_IDS, NO_METADATA, + START_WEBSOCKET, VALID_MEDIA_TYPES, - WS_REMOTE_CONTROL_AVAILABLE, ArtSizeEnum, + BangOlufsenEntity, BangOlufsenMediaType, - BangOlufsenVariables, + EntityEnum, RepeatEnum, SourceEnum, StateEnum, WebSocketNotification, ) -from .coordinator import BangOlufsenCoordinator _LOGGER = logging.getLogger(__name__) @@ -123,6 +114,7 @@ PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(minutes=2) async def async_setup_entry( @@ -131,11 +123,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Media Player entity from config entry.""" - device = hass.data[DOMAIN][config_entry.unique_id][HASS_MEDIA_PLAYER] - async_add_entities([device]) + entity = hass.data[DOMAIN][config_entry.unique_id][EntityEnum.MEDIA_PLAYER] + # Add MediaPlayer entity + async_add_entities(new_entities=[entity], update_before_add=True) # Register services. - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( name="beolink_join", @@ -226,25 +219,22 @@ async def async_setup_entry( ) -class BangOlufsenMediaPlayer( - MediaPlayerEntity, BangOlufsenVariables, CoordinatorEntity -): +class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity): """Representation of a media player.""" - def __init__(self, entry: ConfigEntry, coordinator: BangOlufsenCoordinator) -> None: + def __init__(self, entry: ConfigEntry) -> None: """Initialize the media player.""" MediaPlayerEntity.__init__(self) - BangOlufsenVariables.__init__(self, entry) - CoordinatorEntity.__init__(self, coordinator) + BangOlufsenEntity.__init__(self, entry) # Static entity attributes - self._attr_should_poll = False self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._attr_name = self._name self._attr_icon = "mdi:speaker-wireless" self._attr_supported_features = BANGOLUFSEN_FEATURES self._attr_unique_id = self._unique_id self._attr_group_members = [] + self._attr_should_poll = True self._beolink_jid: str = self.entry.data[CONF_BEOLINK_JID] self._max_volume: int = self.entry.data[CONF_MAX_VOLUME] @@ -258,6 +248,7 @@ def __init__(self, entry: ConfigEntry, coordinator: BangOlufsenCoordinator) -> N self._media_image: Art = Art() self._last_update: datetime = datetime(1970, 1, 1, 0, 0, 0, 0) self._sources: dict[str, str] = {} + self._video_sources: dict[str, str] = {} self._audio_sources: dict[str, str] = {} self._beolink_listeners: list[BeolinkListener] = [] self._remote_leader: BeolinkLeader | None = None @@ -274,100 +265,94 @@ def __init__(self, entry: ConfigEntry, coordinator: BangOlufsenCoordinator) -> N async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PLAYBACK_METADATA}", - self._update_playback_metadata, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PLAYBACK_ERROR}", - self._update_playback_error, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PLAYBACK_PROGRESS}", - self._update_playback_progress, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WebSocketNotification.PLAYBACK_STATE}", - self._update_playback_state, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WebSocketNotification.SOURCE_CHANGE}", - self._update_source_change, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WebSocketNotification.NOTIFICATION}", - self._update_notification, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WebSocketNotification.VOLUME}", - self._update_volume, - ), - async_dispatcher_connect( - self.hass, - f"{self._beolink_jid}_{BEOLINK_LEADER_COMMAND}", - self.async_beolink_leader_command, - ), - async_dispatcher_connect( - self.hass, - f"{self._beolink_jid}_{BEOLINK_LISTENER_COMMAND}", - self.async_beolink_listener_command, - ), - async_dispatcher_connect( - self.hass, - f"{self._beolink_jid}_{BEOLINK_VOLUME}", - self.async_beolink_set_volume, - ), - ] + await self._initialize() - self.async_on_remove( - self.coordinator.async_add_listener(self._update_coordinator_data) + await super().async_added_to_hass() + self._dispatchers.extend( + [ + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_METADATA}", + self._update_playback_metadata, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_ERROR}", + self._update_playback_error, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_PROGRESS}", + self._update_playback_progress, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_STATE}", + self._update_playback_state, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.SOURCE_CHANGE}", + self._update_source_change, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.VOLUME}", + self._update_volume, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.REMOTE_MENU_CHANGED}", + self._update_sources, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.CONFIGURATION}", + self._update_friendly_name, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.BLUETOOTH_DEVICES}", + self._update_bluetooth, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.BEOLINK}", + self._update_beolink, + ), + async_dispatcher_connect( + self.hass, + f"{self._beolink_jid}_{BEOLINK_LEADER_COMMAND}", + self.async_beolink_leader_command, + ), + async_dispatcher_connect( + self.hass, + f"{self._beolink_jid}_{BEOLINK_LISTENER_COMMAND}", + self.async_beolink_listener_command, + ), + async_dispatcher_connect( + self.hass, + f"{self._beolink_jid}_{BEOLINK_VOLUME}", + self.async_beolink_set_volume, + ), + ] ) - # Initialize the entity and WebSocket notification listener - await self.bangolufsen_init() - - async def async_will_remove_from_hass(self) -> None: - """Turn off the WebSocket listener and dispatchers.""" - async_dispatcher_send(self.hass, f"{self._unique_id}_{CLEANUP}") + async def async_update(self) -> None: + """Update polling information.""" + if self._attr_available: + self._queue_settings = self._client.get_settings_queue( + async_req=True, _request_timeout=5 + ).get() - for dispatcher in self._dispatchers: - dispatcher() + async def _initialize(self) -> None: + """Initialize connection dependent variables.""" - async def bangolufsen_init(self) -> bool: - """Initialize network dependent variables.""" # Get software version. self._software_status = self._client.get_softwareupdate_status( async_req=True ).get() - # Update the device sw version - device_registry = dr.async_get(self.hass) - device = cast( - DeviceEntry, - device_registry.async_get_device(identifiers={(DOMAIN, self._unique_id)}), - ) - - # Update the HA device if the sw version does not match - if self._software_status.software_version != device.sw_version: - - device_registry.async_update_device( - device_id=device.id, - sw_version=self._software_status.software_version, - ) - _LOGGER.info( "Connected to: %s %s running SW %s", self._model, @@ -379,9 +364,6 @@ async def bangolufsen_init(self) -> bool: beolink_self = self._client.get_beolink_self(async_req=True).get() self._friendly_name = beolink_self.friendly_name - # If the device has been updated with new sources, then the API will fail here. - await self._get_sources() - # Set the default and maximum volume of the product. self._client.set_volume_settings( volume_settings=VolumeSettings( @@ -392,36 +374,27 @@ async def bangolufsen_init(self) -> bool: ) # Get overall device state once. This is handled by WebSocket events the rest of the time. - try: - product_state = self._client.get_product_state(async_req=True).get() + product_state = self._client.get_product_state(async_req=True).get() - # Get volume information. - self._volume = product_state.volume + # Get volume information. + self._volume = product_state.volume - # Get all playback information. - # Ensure that the metadata is not None upon startup - if product_state.playback.metadata is not None: - self._playback_metadata = product_state.playback.metadata + # Get all playback information. + # Ensure that the metadata is not None upon startup + if product_state.playback.metadata is not None: + self._playback_metadata = product_state.playback.metadata - self._playback_progress = product_state.playback.progress - self._source_change = product_state.playback.source - self._playback_state = product_state.playback.state - - except ValueError: - _LOGGER.warning( - "Error deserializing product state. Defaulting to fallback state" - ) - self._volume = self._client.get_current_volume(async_req=True).get() - self._playback_state = self._client.get_playback_state(async_req=True).get() - self._playback_progress = self._playback_state.progress + self._playback_progress = product_state.playback.progress + self._source_change = product_state.playback.source + self._playback_state = product_state.playback.state self._last_update = utcnow() # Get the highest resolution available of the given images. self._update_artwork() - # Get playback queue settings - self._queue_settings = self._client.get_settings_queue(async_req=True).get() + # If the device has been updated with new sources, then the API will fail here. + await self._update_sources() # Update beolink listener / leader attributes. await self._update_beolink() @@ -436,13 +409,14 @@ async def bangolufsen_init(self) -> bool: await asyncio.sleep(1) # Only receive WebSocket notifications when the dispatchers are ready. - await self.hass.data[DOMAIN][self.entry.unique_id][ - HASS_CONTROLLER - ].start_notification_listener() + async_dispatcher_send(self.hass, f"{self.entry.unique_id}_{START_WEBSOCKET}") - return True + async def _update_friendly_name(self, name: str) -> None: + """Update the device friendly name.""" + self._friendly_name = name + await self._update_beolink() - async def _get_sources(self) -> None: + async def _update_sources(self) -> None: """Get sources for the specific product.""" # Audio sources @@ -461,13 +435,35 @@ async def _get_sources(self) -> None: # Save all of the relevant enabled sources, both the ID and the friendly name for displaying in a dict. self._audio_sources = { - x.id: x.name - for x in sources.items - if x.is_enabled is True and x.id not in HIDDEN_SOURCE_IDS + source.id: source.name + for source in sources.items + if source.is_enabled is True and source.id not in HIDDEN_SOURCE_IDS } + # Video sources from remote menu + menu_items = self._client.get_remote_menu(async_req=True).get() + + for key in menu_items: + menu_item = menu_items[key] + + if not menu_item.available: + continue + + # TV SOURCES + if ( + menu_item.content is not None + and len(menu_item.content.categories) > 0 + and "music" not in menu_item.content.categories + and menu_item.label != "TV" + ): + self._video_sources[key] = menu_item.label + # Combine the source dicts - self._sources.update(self._audio_sources) + self._sources = self._audio_sources | self._video_sources + + # HASS won't be running the first time this method is run + if self.hass.is_running: + self.async_write_ha_state() def _get_beolink_jid(self, entity_id: str) -> str | None: """Get beolink JID from entity_id.""" @@ -597,13 +593,6 @@ async def _update_beolink(self) -> None: self._attr_group_members = group_members - @callback - def _update_coordinator_data(self) -> None: - """Update data from coordinator.""" - self._queue_settings = self.coordinator.data["queue_settings"] - - self.async_write_ha_state() - async def _update_bluetooth(self) -> None: """Update the current bluetooth devices that are connected and paired remotes.""" @@ -634,12 +623,6 @@ async def _update_bluetooth(self) -> None: if not self._bluetooth_attribute["bluetooth"]: self._bluetooth_attribute = None - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self._attr_available = connection_state - - self.async_write_ha_state() - async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None: """Update _playback_metadata and related.""" self._playback_metadata = data @@ -680,38 +663,6 @@ async def _update_source_change(self, data: Source) -> None: self.async_write_ha_state() - async def _update_notification(self, data: WebsocketNotificationTag) -> None: - """Update _notification and Misc. updates.""" - self._notification = data - - # Update beolink - if self._notification.value in ( - "beolinkAvailableListeners", - "beolinkListeners", - "configuration", - ): - # Update the device friendly name - if self._notification.value == "configuration": - beolink_self = self._client.get_beolink_self(async_req=True).get() - self._friendly_name = beolink_self.friendly_name - - await self._update_beolink() - - # Update bluetooth devices - elif self._notification.value == "bluetoothDevices": - await self._update_bluetooth() - - # Update remote control devices - elif self._notification.value == "remoteControlDevices": - # Notify the WebSocket listener that a remote is available - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WS_REMOTE_CONTROL_AVAILABLE}", - ) - await self._update_bluetooth() - - self.async_write_ha_state() - async def _update_volume(self, data: VolumeState) -> None: """Update _volume.""" self._volume = data @@ -998,6 +949,9 @@ async def async_select_source(self, source: str) -> None: if source in self._audio_sources.values(): # Audio self._client.set_active_source(source_id=key, async_req=True) + else: + # Video + self._client.post_remote_trigger(id=key, async_req=True) async def async_join_players(self, group_members: list[str]) -> None: """Create a Beolink session with defined group members.""" @@ -1048,8 +1002,8 @@ async def async_play_media( ) media_id = async_process_play_media_url(self.hass, sourced_media.url) + # Remove playlist extension as it is unsupported. - # A better way may be to open the m3u file and get the stream URI from there(?) if media_id.endswith(".m3u"): media_id = media_id.replace(".m3u", "") diff --git a/custom_components/bangolufsen/number.py b/custom_components/bangolufsen/number.py index 2f7ac24..d7b08e4 100644 --- a/custom_components/bangolufsen/number.py +++ b/custom_components/bangolufsen/number.py @@ -7,16 +7,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONNECTION_STATUS, - DOMAIN, - HASS_NUMBERS, - BangOlufsenVariables, - WebSocketNotification, -) +from .const import DOMAIN, BangOlufsenEntity, EntityEnum, WebSocketNotification async def async_setup_entry( @@ -27,46 +21,23 @@ async def async_setup_entry( """Set up Number entities from config entry.""" entities = [] - # Add number entities. - for number in hass.data[DOMAIN][config_entry.unique_id][HASS_NUMBERS]: + # Add Number entities. + for number in hass.data[DOMAIN][config_entry.unique_id][EntityEnum.NUMBERS]: entities.append(number) - async_add_entities(new_entities=entities, update_before_add=True) + async_add_entities(new_entities=entities) -class BangOlufsenNumber(BangOlufsenVariables, NumberEntity): +class BangOlufsenNumber(BangOlufsenEntity, NumberEntity): """Base Number class.""" def __init__(self, entry: ConfigEntry) -> None: """Init the Number.""" super().__init__(entry) - self._attr_entity_category = EntityCategory.CONFIG - self._attr_should_poll = False self._attr_mode = NumberMode.AUTO - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) self._attr_native_value = 0.0 - - async def async_added_to_hass(self) -> None: - """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ) - ] - - async def async_will_remove_from_hass(self) -> None: - """Turn off the dispatchers.""" - for dispatcher in self._dispatchers: - dispatcher() - - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self._attr_available = connection_state - - self.async_write_ha_state() + self._attr_entity_category = EntityCategory.CONFIG class BangOlufsenNumberTreble(BangOlufsenNumber): @@ -92,24 +63,19 @@ async def async_set_native_value(self, value: float) -> None: async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ + await super().async_added_to_hass() + + self._dispatchers.append( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebSocketNotification.SOUND_SETTINGS}", self._update_sound_settings, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - ] + ) + ) async def _update_sound_settings(self, data: SoundSettings) -> None: """Update sound settings.""" - self._sound_settings = data - self._attr_native_value = self._sound_settings.adjustments.treble - + self._attr_native_value = data.adjustments.treble self.async_write_ha_state() @@ -136,22 +102,17 @@ async def async_set_native_value(self, value: float) -> None: async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ + await super().async_added_to_hass() + + self._dispatchers.append( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebSocketNotification.SOUND_SETTINGS}", self._update_sound_settings, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - ] + ) + ) async def _update_sound_settings(self, data: SoundSettings) -> None: """Update sound settings.""" - self._sound_settings = data - self._attr_native_value = self._sound_settings.adjustments.bass - + self._attr_native_value = data.adjustments.bass self.async_write_ha_state() diff --git a/custom_components/bangolufsen/select.py b/custom_components/bangolufsen/select.py index 76b5052..d1c4753 100644 --- a/custom_components/bangolufsen/select.py +++ b/custom_components/bangolufsen/select.py @@ -1,30 +1,21 @@ """Select entities for the Bang & Olufsen Mozart integration.""" from __future__ import annotations -from datetime import timedelta import logging -from mozart_api.models import ListeningModeProps +from mozart_api.models import ListeningModeProps, SpeakerGroupOverview from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONNECTION_STATUS, - DOMAIN, - HASS_SELECTS, - BangOlufsenVariables, - WebSocketNotification, -) +from .const import DOMAIN, BangOlufsenEntity, EntityEnum, WebSocketNotification _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=2) - async def async_setup_entry( hass: HomeAssistant, @@ -34,101 +25,154 @@ async def async_setup_entry( """Set up Select entities from config entry.""" entities = [] - # Add select entities. - for select in hass.data[DOMAIN][config_entry.unique_id][HASS_SELECTS]: + # Add Select entities. + for select in hass.data[DOMAIN][config_entry.unique_id][EntityEnum.SELECTS]: entities.append(select) - async_add_entities(new_entities=entities, update_before_add=True) + async_add_entities(new_entities=entities) -class BangOlufsenSelect(BangOlufsenVariables, SelectEntity): +class BangOlufsenSelect(BangOlufsenEntity, SelectEntity): """Select for Mozart settings.""" def __init__(self, entry: ConfigEntry) -> None: """Init the Select.""" super().__init__(entry) + self._attr_options = [] + self._attr_current_option = None self._attr_entity_category = EntityCategory.CONFIG - self._attr_should_poll = False - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) + + +class BangOlufsenSelectSoundMode(BangOlufsenSelect): + """Sound mode Select.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Init the sound mode select.""" + super().__init__(entry) + + self._attr_name = f"{self._name} Sound mode" + self._attr_unique_id = f"{self._unique_id}-sound-mode" + self._attr_icon = "mdi:sine-wave" + + self._sound_modes: dict[str, int] = {} async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ + await super().async_added_to_hass() + + self._dispatchers.append( async_dispatcher_connect( self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, + f"{self._unique_id}_{WebSocketNotification.ACTIVE_LISTENING_MODE}", + self._update_sound_modes, ) - ] + ) + + await self._update_sound_modes() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self._client.activate_listening_mode( + id=self._sound_modes[option], async_req=True + ) + + async def _update_sound_modes( + self, active_sound_mode: ListeningModeProps | None = None + ) -> None: + """Get the available sound modes and setup Select functionality.""" + sound_modes = self._client.get_listening_mode_set(async_req=True).get() + if active_sound_mode is None: + active_sound_mode = self._client.get_active_listening_mode( + async_req=True + ).get() - async def async_will_remove_from_hass(self) -> None: - """Turn off the dispatchers.""" - for dispatcher in self._dispatchers: - dispatcher() + # Add the key to make the labels unique as well + for sound_mode in sound_modes: + label = f"{sound_mode['name']} - {sound_mode['id']}" + + self._sound_modes[label] = sound_mode["id"] + + if sound_mode["id"] == active_sound_mode.id: + self._attr_current_option = label - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self._attr_available = connection_state + # Set available options and selected option. + self._attr_options = list(self._sound_modes.keys()) self.async_write_ha_state() -class BangOlufsenSelectSoundMode(BangOlufsenSelect): - """Sound mode Select.""" +class BangOlufsenSelectListeningPosition(BangOlufsenSelect): + """Listening position Select.""" def __init__(self, entry: ConfigEntry) -> None: - """Init the sound mode select.""" + """Init the listening position select.""" super().__init__(entry) - self._attr_name = f"{self._name} Sound mode" - self._attr_unique_id = f"{self._unique_id}-sound-mode" + self._attr_name = f"{self._name} Listening position" + self._attr_unique_id = f"{self._unique_id}-listening-position" self._attr_icon = "mdi:sine-wave" - self._attr_should_poll = True - self._sound_modes: dict[int, str] = {} + self._listening_positions: dict[str, str] = {} + self._scenes: dict[str, str] = {} async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WebSocketNotification.ACTIVE_LISTENING_MODE}", - self._update_sound_mode, - ), - ] + await super().async_added_to_hass() + + self._dispatchers.extend( + [ + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.ACTIVE_SPEAKER_GROUP}", + self._update_listening_positions, + ), + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.REMOTE_MENU_CHANGED}", + self._update_listening_positions, + ), + ] + ) + + await self._update_listening_positions() async def async_select_option(self, option: str) -> None: """Change the selected option.""" - key = [x for x in self._sound_modes if self._sound_modes[x] == option][0] + self._client.post_scene_trigger( + id=self._listening_positions[option], async_req=True + ) - self._client.activate_listening_mode(id=key, async_req=True) + async def _update_listening_positions( + self, active_speaker_group: SpeakerGroupOverview | None = None + ) -> None: + """Update listening position.""" + scenes = self._client.get_all_scenes(async_req=True).get() - async def _update_sound_mode(self, data: ListeningModeProps) -> None: - """Update sound mode.""" - active_sound_mode = data - self._attr_current_option = self._sound_modes[active_sound_mode.id] + if active_speaker_group is None: + active_speaker_group = self._client.get_speakergroup_active( + async_req=True + ).get() - self.async_write_ha_state() + self._listening_positions = {} + index = 0 - async def async_update(self) -> None: - """Get the available sound modes and setup Select functionality.""" - sound_modes = self._client.get_listening_mode_set(async_req=True).get() - active_sound_mode = self._client.get_active_listening_mode(async_req=True).get() + # Listening positions + for scene_key in scenes: + scene = scenes[scene_key] - # Add the key to make the labels unique as well - self._sound_modes = {x["id"]: f"{x['name']} - {x['id']}" for x in sound_modes} + if scene.tags is not None and "listeningposition" in scene.tags: + # Ensure that the label is unique + label = f"{scene.label} - {index}" - # Set available options and selected option. - self._attr_options = list(self._sound_modes.values()) + self._listening_positions[label] = scene_key + + # Currently guess the current active listening position by the speakergroup ID + if active_speaker_group.id == scene.action_list[0].speaker_group_id: + self._attr_current_option = label + + index += 1 - # Temp fix for any invalid active sound mode - try: - self._attr_current_option = self._sound_modes[active_sound_mode.id] - except KeyError: - self._attr_current_option = None + self._attr_options = list(self._listening_positions.keys()) + + self.async_write_ha_state() diff --git a/custom_components/bangolufsen/sensor.py b/custom_components/bangolufsen/sensor.py index 6056850..92a4a42 100644 --- a/custom_components/bangolufsen/sensor.py +++ b/custom_components/bangolufsen/sensor.py @@ -1,7 +1,10 @@ """Sensor entities for the Bang & Olufsen integration.""" from __future__ import annotations -from mozart_api.models import BatteryState +import logging + +from inflection import titleize, underscore +from mozart_api.models import BatteryState, PlaybackContentMetadata from homeassistant.components.sensor import ( SensorDeviceClass, @@ -11,16 +14,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONNECTION_STATUS, - DOMAIN, - HASS_SENSORS, - BangOlufsenVariables, - WebSocketNotification, -) +from .const import DOMAIN, BangOlufsenEntity, EntityEnum, WebSocketNotification + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -31,14 +29,14 @@ async def async_setup_entry( """Set up Sensor entities from config entry.""" entities = [] - # Add sensor entities. - for sensor in hass.data[DOMAIN][config_entry.unique_id][HASS_SENSORS]: + # Add Sensor entities. + for sensor in hass.data[DOMAIN][config_entry.unique_id][EntityEnum.SENSORS]: entities.append(sensor) - async_add_entities(new_entities=entities, update_before_add=True) + async_add_entities(new_entities=entities) -class BangOlufsenSensor(BangOlufsenVariables, SensorEntity): +class BangOlufsenSensor(BangOlufsenEntity, SensorEntity): """Base Sensor class.""" def __init__(self, entry: ConfigEntry) -> None: @@ -46,29 +44,6 @@ def __init__(self, entry: ConfigEntry) -> None: super().__init__(entry) self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_should_poll = False - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) - - async def async_added_to_hass(self) -> None: - """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ) - ] - - async def async_will_remove_from_hass(self) -> None: - """Turn off the dispatchers.""" - for dispatcher in self._dispatchers: - dispatcher() - - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self._attr_available = connection_state - - self.async_write_ha_state() class BangOlufsenSensorBatteryLevel(BangOlufsenSensor): @@ -86,23 +61,19 @@ def __init__(self, entry: ConfigEntry) -> None: async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ + await super().async_added_to_hass() + + self._dispatchers.append( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebSocketNotification.BATTERY}", self._update_battery, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - ] + ) + ) async def _update_battery(self, data: BatteryState) -> None: """Update sensor value.""" - self._battery = data - self._attr_native_value = self._battery.battery_level + self._attr_native_value = data.battery_level self.async_write_ha_state() @@ -122,26 +93,22 @@ def __init__(self, entry: ConfigEntry) -> None: async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ + await super().async_added_to_hass() + + self._dispatchers.append( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebSocketNotification.BATTERY}", self._update_battery, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - ] + ) + ) async def _update_battery(self, data: BatteryState) -> None: """Update sensor value.""" - self._battery = data self._attr_available = True - charging_time = self._battery.remaining_charging_time_minutes + charging_time = data.remaining_charging_time_minutes # The charging time is 65535 if the device is not charging. if charging_time == 65535: @@ -162,34 +129,27 @@ def __init__(self, entry: ConfigEntry) -> None: self._attr_name = f"{self._name} Battery playing time" self._attr_unique_id = f"{self._unique_id}-battery-playing-time" - self._attr_device_class = SensorDeviceClass.DURATION self._attr_native_unit_of_measurement = "min" - self._attr_icon = "mdi:battery-arrow-down" self._attr_entity_registry_enabled_default = False async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ + await super().async_added_to_hass() + + self._dispatchers.append( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebSocketNotification.BATTERY}", self._update_battery, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - ] + ) + ) - async def _update_battery(self, data: BatteryState) -> None: + async def _update_battery(self, data: PlaybackContentMetadata) -> None: """Update sensor value.""" - self._battery = data - self._attr_available = True - playing_time = self._battery.remaining_playing_time_minutes + playing_time = data.remaining_playing_time_minutes # The playing time is 65535 if the device is charging if playing_time == 65535: @@ -199,3 +159,85 @@ async def _update_battery(self, data: BatteryState) -> None: self._attr_native_value = playing_time self.async_write_ha_state() + + +class BangOlufsenSensorMediaId(BangOlufsenSensor): + """Media id Sensor.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Init the media id Sensor.""" + super().__init__(entry) + + self._attr_name = f"{self._name} Media Id" + self._attr_unique_id = f"{self._unique_id}-media-id" + self._attr_icon = "mdi:information" + self._attr_device_class = None + self._attr_state_class = None + self._attr_entity_registry_enabled_default = False + + self._attr_native_value = None + + async def async_added_to_hass(self) -> None: + """Turn on the dispatchers.""" + await super().async_added_to_hass() + + self._dispatchers.append( + async_dispatcher_connect( + self.hass, + f"{self.entry.unique_id}_{WebSocketNotification.PLAYBACK_METADATA}", + self._update_playback_metadata, + ), + ) + + async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None: + """Update Sensor value.""" + self._attr_native_value = data.source_internal_id + self.async_write_ha_state() + + +class BangOlufsenSensorInputSignal(BangOlufsenSensor): + """Input signal Sensor.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Init the input signal Sensor.""" + super().__init__(entry) + + self._attr_name = f"{self._name} Input signal" + self._attr_unique_id = f"{self._unique_id}-input-signal" + self._attr_device_class = None + self._attr_state_class = None + self._attr_icon = "mdi:audio-input-stereo-minijack" + self._attr_entity_registry_enabled_default = False + + async def async_added_to_hass(self) -> None: + """Turn on the dispatchers.""" + await super().async_added_to_hass() + + self._dispatchers.append( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebSocketNotification.PLAYBACK_METADATA}", + self._update_playback_metadata, + ) + ) + + async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None: + """Update Sensor value.""" + if data.encoding: + + # Ensure that abbreviated formats are capitialized and non-abbreviated formats are made "human readable" + encoding = titleize(underscore(data.encoding)) + if data.encoding.capitalize() == encoding: + encoding = data.encoding.upper() + + input_channel_processing = None + if data.input_channel_processing: + input_channel_processing = titleize( + underscore(data.input_channel_processing) + ) + + self._attr_native_value = f"{encoding}{f' - {input_channel_processing}' if input_channel_processing else ''}{f' - {data.input_channels}' if data.input_channels else ''}" + else: + self._attr_native_value = None + + self.async_write_ha_state() diff --git a/custom_components/bangolufsen/strings.json b/custom_components/bangolufsen/strings.json index d63b584..d8ce1ba 100644 --- a/custom_components/bangolufsen/strings.json +++ b/custom_components/bangolufsen/strings.json @@ -5,7 +5,6 @@ "api_exception": "An error occurred while initializing the device. Try waiting or restarting the device.", "max_retry_error": "Unable to connect to the device at the address. Please check if the IP address is correct.", "new_connection_error": "Unable to detect a device at the address. Please check if the IP address is correct.", - "no_device": "Unable to retrieve device.", "value_error": "[%key:common::config_flow::error::invalid_host%]" }, "flow_title": "{name}", @@ -14,7 +13,6 @@ "data": { "default_volume": "Default volume", "max_volume": "Max volume", - "name": "[%key:common::config_flow::data::name%]", "volume_step": "Volume step" }, "description": "Confirm the configuration of the {model}-{serial_number} @ {host}." @@ -141,14 +139,12 @@ "api_exception": "An error occurred while initializing the device. Try waiting or restarting the device.", "max_retry_error": "Unable to connect to the device at the address. Please check if the IP address is correct.", "new_connection_error": "Unable to detect a device at the address. Please check if the IP address is correct.", - "no_device": "Unable to retrieve device.", "value_error": "[%key:common::config_flow::error::invalid_host%]" }, "step": { "init": { "data": { "default_volume": "Default volume", - "host": "[%key:common::config_flow::data::ip%]", "max_volume": "Max volume", "name": "[%key:common::config_flow::data::name%]", "volume_step": "Volume step" diff --git a/custom_components/bangolufsen/switch.py b/custom_components/bangolufsen/switch.py index 61c61a5..099d1ef 100644 --- a/custom_components/bangolufsen/switch.py +++ b/custom_components/bangolufsen/switch.py @@ -1,7 +1,6 @@ """Switch entities for the Bang & Olufsen integration.""" from __future__ import annotations -from datetime import timedelta from typing import Any from mozart_api.models import Loudness, SoundSettings @@ -10,18 +9,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONNECTION_STATUS, - DOMAIN, - HASS_SWITCHES, - BangOlufsenVariables, - WebSocketNotification, -) - -SCAN_INTERVAL = timedelta(seconds=120) +from .const import DOMAIN, BangOlufsenEntity, EntityEnum, WebSocketNotification async def async_setup_entry( @@ -33,52 +24,21 @@ async def async_setup_entry( entities = [] # Add switch entities. - for switch in hass.data[DOMAIN][config_entry.unique_id][HASS_SWITCHES]: + for switch in hass.data[DOMAIN][config_entry.unique_id][EntityEnum.SWITCHES]: entities.append(switch) - async_add_entities(new_entities=entities, update_before_add=True) + async_add_entities(new_entities=entities) -class BangOlufsenSwitch(BangOlufsenVariables, SwitchEntity): +class BangOlufsenSwitch(BangOlufsenEntity, SwitchEntity): """Base Switch class.""" def __init__(self, entry: ConfigEntry) -> None: """Init the Switch.""" super().__init__(entry) - self._attr_entity_category = EntityCategory.CONFIG self._attr_device_class = SwitchDeviceClass.SWITCH - self._attr_should_poll = False - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) - self._attr_is_on = False - - async def async_added_to_hass(self) -> None: - """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ) - ] - - async def async_will_remove_from_hass(self) -> None: - """Turn off the dispatchers.""" - for dispatcher in self._dispatchers: - dispatcher() - - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self._attr_available = connection_state - - self.async_write_ha_state() - - async def async_toggle(self, **kwargs: Any) -> None: - """Toggle the option.""" - if self._attr_is_on: - await self.async_turn_off() - else: - await self.async_turn_on() + self._attr_entity_category = EntityCategory.CONFIG class BangOlufsenSwitchLoudness(BangOlufsenSwitch): @@ -108,22 +68,17 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ + await super().async_added_to_hass() + + self._dispatchers.append( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebSocketNotification.SOUND_SETTINGS}", self._update_sound_settings, - ), - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), - ] + ) + ) async def _update_sound_settings(self, data: SoundSettings) -> None: """Update sound settings.""" - sound_settings = data - self._attr_is_on = sound_settings.adjustments.loudness - + self._attr_is_on = data.adjustments.loudness self.async_write_ha_state() diff --git a/custom_components/bangolufsen/text.py b/custom_components/bangolufsen/text.py index a2cccac..b665d05 100644 --- a/custom_components/bangolufsen/text.py +++ b/custom_components/bangolufsen/text.py @@ -1,21 +1,19 @@ -"""Button entities for the Bang & Olufsen integration.""" +"""Text entities for the Bang & Olufsen integration.""" +# pylint: disable=unused-argument + + from __future__ import annotations -import logging +from mozart_api.models import HomeControlUri, ProductFriendlyName from homeassistant.components.text import TextEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONNECTION_STATUS, DOMAIN, HASS_TEXT, MEDIA_ID, BangOlufsenVariables - -# from mozart_api.models import HomeControlUri - - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, BangOlufsenEntity, EntityEnum, WebSocketNotification async def async_setup_entry( @@ -27,78 +25,81 @@ async def async_setup_entry( entities = [] configuration = hass.data[DOMAIN][config_entry.unique_id] - # Add favourite Button entities. - for text in configuration[HASS_TEXT]: + # Add Text entities. + for text in configuration[EntityEnum.TEXT]: entities.append(text) - async_add_entities(new_entities=entities, update_before_add=True) + async_add_entities(new_entities=entities) -class BangOlufsenText(TextEntity, BangOlufsenVariables): +class BangOlufsenText(TextEntity, BangOlufsenEntity): """Base Text class.""" def __init__(self, entry: ConfigEntry) -> None: """Init the Text.""" super().__init__(entry) - self._attr_entity_category = None - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) - - async def async_added_to_hass(self) -> None: - """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ) - ] - - async def async_will_remove_from_hass(self) -> None: - """Turn off the dispatchers.""" - for dispatcher in self._dispatchers: - dispatcher() - - async def _update_connection_state(self, connection_state: bool) -> None: - """Update entity connection state.""" - self._attr_available = connection_state - - self.async_write_ha_state() + self._attr_entity_category = EntityCategory.CONFIG -class BangOlufsenTextMediaId(BangOlufsenText): - """Media id Text.""" +class BangOlufsenTextFriendlyName(BangOlufsenText): + """Friendly name Text.""" - def __init__(self, entry: ConfigEntry) -> None: - """Init the media id Text.""" + def __init__(self, entry: ConfigEntry, friendly_name: str) -> None: + """Init the friendly name Text.""" super().__init__(entry) - self._attr_entity_category = EntityCategory.DIAGNOSTIC - self._attr_name = f"{self._name} Media Id" - self._attr_unique_id = f"{self._unique_id}-media-id" - self._attr_device_class = None - self._attr_icon = "mdi:information" - self._attr_entity_registry_enabled_default = False + self._attr_name = f"{self._name} Friendly name" + self._attr_unique_id = f"{self._unique_id}-friendly-name" + self._attr_icon = "mdi:id-card" - self._attr_native_value = None + self._attr_native_value = friendly_name async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" - self._dispatchers = [ - async_dispatcher_connect( - self.hass, - f"{self.entry.unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, - ), + await super().async_added_to_hass() + + self._dispatchers.append( async_dispatcher_connect( self.hass, - f"{self.entry.unique_id}_{MEDIA_ID}", - self._update_media_id, + f"{self.entry.unique_id}_{WebSocketNotification.CONFIGURATION}", + self._update_friendly_name, ), - ] + ) + + async def async_set_value(self, value: str) -> None: + """Set the friendly name.""" + self._attr_native_value = value + self._client.set_product_friendly_name( + product_friendly_name=ProductFriendlyName(friendly_name=value), + async_req=True, + ) - async def _update_media_id(self, data: str | None) -> None: + async def _update_friendly_name(self, data: str | None) -> None: """Update text value.""" - self._attr_native_value = data + beolink_self = self._client.get_beolink_self(async_req=True).get() + + self._attr_native_value = beolink_self.friendly_name self.async_write_ha_state() + + +class BangOlufsenTextHomeControlUri(BangOlufsenText): + """Home Control URI Text.""" + + def __init__(self, entry: ConfigEntry, home_control_uri: str) -> None: + """Init the Home Control URI Text.""" + super().__init__(entry) + + self._attr_name = f"{self._name} Home Control URI" + self._attr_unique_id = f"{self._unique_id}-home-control-uri" + self._attr_icon = "mdi:link-variant" + self._attr_native_value = home_control_uri + + async def async_set_value(self, value: str) -> None: + """Set the Home Control URI name.""" + self._attr_native_value = value + + self._client.set_remote_home_control_uri( + home_control_uri=HomeControlUri(uri=value), async_req=True + ) diff --git a/custom_components/bangolufsen/translations/da.json b/custom_components/bangolufsen/translations/da.json index bd89ba9..c98d2ff 100644 --- a/custom_components/bangolufsen/translations/da.json +++ b/custom_components/bangolufsen/translations/da.json @@ -5,7 +5,6 @@ "api_exception": "En fejl opstod ved initialiseringen af enheden. Prøv at vente eller at genstarte enheden.", "max_retry_error": "Kan ikke forbinde til enheden på den givne IP-adresse. Tjek om IP-adressen stemmer overens med enhedens.", "new_connection_error": "Kan ikke detektere en enhed på givne IP-adresse. Tjek om IP-adressen stemmer overens med enhedens.", - "no_device": "Kunne ikke finde enheden.", "value_error": "Ikke en gyldig IP-adresse." }, "flow_title": "{name}", @@ -14,10 +13,9 @@ "data":{ "default_volume": "Standard lydstyrke", "max_volume": "Maksimal lydstyrke", - "name": "Navn", "volume_step": "Lydstyrke trin" }, - "description": "Bekræft Konfigurationen af {model}-{serial_number} @ {host}." + "description": "Bekræft konfigurationen af {model}-{serial_number} @ {host}." }, "user":{ "data": { @@ -142,14 +140,12 @@ "api_exception": "En fejl opstod ved initialiseringen af enheden. Prøv at vente eller at genstarte enheden.", "max_retry_error": "Kan ikke forbinde til enheden på den givne IP-adresse. Tjek om IP-adressen stemmer overens med enhedens.", "new_connection_error": "Kan ikke detektere en enhed på givne IP-adresse. Tjek om IP-adressen stemmer overens med enhedens.", - "no_device": "Kunne ikke finde enheden.", "value_error": "Ikke en gyldig IP-adresse." }, "step":{ "init": { "data": { "default_volume": "Standard lydstyrke", - "host": "IP-Addresse", "max_volume": "Maksimal lydstyrke", "name": "Navn", "volume_step": "Lydstyrke trin" diff --git a/custom_components/bangolufsen/translations/en.json b/custom_components/bangolufsen/translations/en.json index 0eabe6d..e9e4455 100644 --- a/custom_components/bangolufsen/translations/en.json +++ b/custom_components/bangolufsen/translations/en.json @@ -5,7 +5,6 @@ "api_exception": "An error occurred while initializing the device. Try waiting or restarting the device.", "max_retry_error": "Unable to connect to the device at the address. Please check if the IP address is correct.", "new_connection_error": "Unable to detect a device at the address. Please check if the IP address is correct.", - "no_device": "Unable to retrieve device.", "value_error": "Invalid hostname or IP address" }, "flow_title": "{name}", @@ -14,7 +13,6 @@ "data": { "default_volume": "Default volume", "max_volume": "Max volume", - "name": "Name", "volume_step": "Volume step" }, "description": "Confirm the configuration of the {model}-{serial_number} @ {host}." @@ -141,14 +139,12 @@ "api_exception": "An error occurred while initializing the device. Try waiting or restarting the device.", "max_retry_error": "Unable to connect to the device at the address. Please check if the IP address is correct.", "new_connection_error": "Unable to detect a device at the address. Please check if the IP address is correct.", - "no_device": "Unable to retrieve device.", "value_error": "Invalid hostname or IP address" }, "step": { "init": { "data": { "default_volume": "Default volume", - "host": "IP Address", "max_volume": "Max volume", "name": "Name", "volume_step": "Volume step"