Update integration with feedback from emontnemery

This commit is contained in:
mj23000
2023-12-01 17:03:25 +01:00
parent 3b3a77b5d0
commit 6061171bb4
14 changed files with 309 additions and 507 deletions

View File

@@ -114,6 +114,7 @@ omit =
homeassistant/components/bangolufsen/const.py homeassistant/components/bangolufsen/const.py
homeassistant/components/bangolufsen/entity.py homeassistant/components/bangolufsen/entity.py
homeassistant/components/bangolufsen/media_player.py homeassistant/components/bangolufsen/media_player.py
homeassistant/components/bangolufsen/util.py
homeassistant/components/bangolufsen/websocket.py homeassistant/components/bangolufsen/websocket.py
homeassistant/components/bbox/device_tracker.py homeassistant/components/bbox/device_tracker.py
homeassistant/components/bbox/sensor.py homeassistant/components/bbox/sensor.py

View File

@@ -1,42 +1,53 @@
"""The Bang & Olufsen integration.""" """The Bang & Olufsen integration."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import logging import logging
from multiprocessing.pool import ApplyResult
from typing import cast
from mozart_api.exceptions import ServiceException from mozart_api.exceptions import ServiceException
from mozart_api.models import BatteryState
from mozart_api.mozart_client import MozartClient from mozart_api.mozart_client import MozartClient
from urllib3.exceptions import MaxRetryError from urllib3.exceptions import MaxRetryError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from .const import DOMAIN, ENTITY_ENUM, WEBSOCKET_CONNECTION_DELAY from .const import DOMAIN, WEBSOCKET_CONNECTION_DELAY
from .media_player import BangOlufsenMediaPlayer
from .websocket import BangOlufsenWebsocket from .websocket import BangOlufsenWebsocket
PLATFORMS = [Platform.MEDIA_PLAYER] PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@dataclass
class BangOlufsenData:
"""Dataclass for storing entities."""
websocket: BangOlufsenWebsocket
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry.""" """Set up from a config entry."""
hass.data.setdefault(DOMAIN, {})
# Ensure that a unique id is available client = MozartClient(
if not entry.unique_id: host=entry.data[CONF_HOST], urllib3_logging_level=logging.DEBUG
raise ConfigEntryError("Can't retrieve unique id from config entry. Aborting") )
# If connection can't be made abort. # Check connection and try to initialize it.
if not await init_entities(hass, entry): try:
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") await client.get_battery_state(_request_timeout=3)
except (MaxRetryError, ServiceException, Exception) as error:
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
websocket = BangOlufsenWebsocket(hass, entry)
# Add the websocket
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BangOlufsenData(websocket)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async_call_later(hass, WEBSOCKET_CONNECTION_DELAY, websocket.connect_websocket)
return True return True
@@ -46,39 +57,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.unique_id) hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok return unload_ok
async def init_entities(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Initialise the supported entities of the device."""
client = MozartClient(
host=entry.data[CONF_HOST], urllib3_logging_level=logging.ERROR
)
# Check connection and try to initialize it.
try:
cast(
ApplyResult[BatteryState],
client.get_battery_state(async_req=True, _request_timeout=3),
).get()
except (MaxRetryError, ServiceException):
_LOGGER.error("Unable to connect to %s", entry.data[CONF_NAME])
return False
websocket = BangOlufsenWebsocket(hass, entry)
# Create the Media Player entity.
media_player = BangOlufsenMediaPlayer(entry)
# Add the created entities
hass.data[DOMAIN][entry.unique_id] = {
ENTITY_ENUM.WEBSOCKET: websocket,
ENTITY_ENUM.MEDIA_PLAYER: media_player,
}
# Start the WebSocket listener with a delay to allow for entity and dispatcher listener creation
async_call_later(hass, WEBSOCKET_CONNECTION_DELAY, websocket.connect_websocket)
return True

View File

@@ -3,11 +3,9 @@ from __future__ import annotations
import ipaddress import ipaddress
import logging import logging
from multiprocessing.pool import ApplyResult from typing import Any, TypedDict
from typing import Any, TypedDict, cast
from mozart_api.exceptions import ApiException, NotFoundException from mozart_api.exceptions import ApiException, NotFoundException
from mozart_api.models import BeolinkPeer, VolumeSettings
from mozart_api.mozart_client import MozartClient from mozart_api.mozart_client import MozartClient
from urllib3.exceptions import MaxRetryError, NewConnectionError from urllib3.exceptions import MaxRetryError, NewConnectionError
import voluptuous as vol import voluptuous as vol
@@ -28,11 +26,9 @@ from .const import (
CONF_DEFAULT_VOLUME, CONF_DEFAULT_VOLUME,
CONF_MAX_VOLUME, CONF_MAX_VOLUME,
CONF_SERIAL_NUMBER, CONF_SERIAL_NUMBER,
CONF_VOLUME_STEP,
DEFAULT_DEFAULT_VOLUME, DEFAULT_DEFAULT_VOLUME,
DEFAULT_MAX_VOLUME, DEFAULT_MAX_VOLUME,
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_VOLUME_STEP,
DOMAIN, DOMAIN,
) )
@@ -40,40 +36,33 @@ from .const import (
class UserInput(TypedDict, total=False): class UserInput(TypedDict, total=False):
"""TypedDict for user_input.""" """TypedDict for user_input."""
name: str
volume_step: int
default_volume: int default_volume: int
max_volume: int
host: str host: str
model: str
jid: str jid: str
max_volume: int
model: str
name: str
class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN): class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow.""" """Handle a config flow."""
_beolink_jid = ""
_client: MozartClient
_host = ""
_model = ""
_name = ""
_serial_number = ""
def __init__(self) -> None: def __init__(self) -> None:
"""Init the config flow.""" """Init the config flow."""
self._host: str = ""
self._name: str = ""
self._model: str = ""
self._serial_number: str = ""
self._beolink_jid: str = ""
self._client: MozartClient | None = None
VERSION = 1 VERSION = 1
async def _compile_data(self) -> UserInput: async def _compile_data(self) -> UserInput:
"""Compile data for entry creation.""" """Compile data for entry creation."""
if not self._client:
self._client = MozartClient(self._host, urllib3_logging_level=logging.ERROR)
# Get current volume settings # Get current volume settings
volume_settings = cast( volume_settings = await self._client.get_volume_settings()
ApplyResult[VolumeSettings],
self._client.get_volume_settings(async_req=True),
).get()
# Create a dict containing all necessary information for setup # Create a dict containing all necessary information for setup
data = UserInput() data = UserInput()
@@ -81,7 +70,6 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
data[CONF_HOST] = self._host data[CONF_HOST] = self._host
data[CONF_MODEL] = self._model data[CONF_MODEL] = self._model
data[CONF_BEOLINK_JID] = self._beolink_jid data[CONF_BEOLINK_JID] = self._beolink_jid
data[CONF_VOLUME_STEP] = DEFAULT_VOLUME_STEP
data[CONF_DEFAULT_VOLUME] = ( data[CONF_DEFAULT_VOLUME] = (
volume_settings.default.level volume_settings.default.level
if volume_settings.default and volume_settings.default.level if volume_settings.default and volume_settings.default.level
@@ -114,10 +102,7 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
# Try to get information from Beolink self method. # Try to get information from Beolink self method.
try: try:
beolink_self = cast( beolink_self = await self._client.get_beolink_self(_request_timeout=3)
ApplyResult[BeolinkPeer],
self._client.get_beolink_self(async_req=True, _request_timeout=3),
).get()
except ( except (
ApiException, ApiException,
@@ -173,7 +158,9 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
} }
await self.async_set_unique_id(self._serial_number) await self.async_set_unique_id(self._serial_number)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
self._client = MozartClient(self._host, urllib3_logging_level=logging.ERROR)
return await self.async_step_confirm() return await self.async_step_confirm()

View File

@@ -2,37 +2,12 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, StrEnum from enum import StrEnum
import logging from typing import Final
from typing import Final, cast
from mozart_api.models import ( from mozart_api.models import Source, SourceArray, SourceTypeEnum
PlaybackContentMetadata,
PlaybackProgress,
RenderingState,
Source,
SourceArray,
SourceTypeEnum,
VolumeLevel,
VolumeMute,
VolumeState,
)
from mozart_api.mozart_client import MozartClient
from homeassistant.components.media_player import MediaPlayerState, MediaType from homeassistant.components.media_player import MediaPlayerState, MediaType
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
class ART_SIZE_ENUM(Enum):
"""Enum used for sorting images that have size defined by a string."""
small = 1
medium = 2
large = 3
class SOURCE_ENUM(StrEnum): class SOURCE_ENUM(StrEnum):
@@ -58,14 +33,6 @@ class SOURCE_ENUM(StrEnum):
tidalConnect = "Tidal Connect" # noqa: N815 tidalConnect = "Tidal Connect" # noqa: N815
class REPEAT_ENUM(StrEnum):
"""Enum used for translating device repeat settings to Home Assistant settings."""
all = "all"
one = "track"
off = "none"
BANGOLUFSEN_STATES: dict[str, MediaPlayerState] = { BANGOLUFSEN_STATES: dict[str, MediaPlayerState] = {
# Dict used for translating device states to Home Assistant states. # Dict used for translating device states to Home Assistant states.
"started": MediaPlayerState.PLAYING, "started": MediaPlayerState.PLAYING,
@@ -75,9 +42,8 @@ BANGOLUFSEN_STATES: dict[str, MediaPlayerState] = {
"stopped": MediaPlayerState.PAUSED, "stopped": MediaPlayerState.PAUSED,
"ended": MediaPlayerState.PAUSED, "ended": MediaPlayerState.PAUSED,
"error": MediaPlayerState.IDLE, "error": MediaPlayerState.IDLE,
# A devices initial state is "unknown" and should be treated as "idle" # A device's initial state is "unknown" and should be treated as "idle"
"unknown": MediaPlayerState.IDLE, "unknown": MediaPlayerState.IDLE,
# Power states
} }
@@ -105,13 +71,6 @@ class MODEL_ENUM(StrEnum):
BEOSOUND_THEATRE = "Beosound Theatre" BEOSOUND_THEATRE = "Beosound Theatre"
class ENTITY_ENUM(StrEnum):
"""Enum for accessing and storing the entities in hass."""
MEDIA_PLAYER = "media_player"
WEBSOCKET = "websocket"
# Dispatcher events # Dispatcher events
class WEBSOCKET_NOTIFICATION(StrEnum): class WEBSOCKET_NOTIFICATION(StrEnum):
"""Enum for WebSocket notification types.""" """Enum for WebSocket notification types."""
@@ -148,7 +107,6 @@ VOLUME_STEP_RANGE: Final[range] = range(1, 20, 1)
# Configuration. # Configuration.
CONF_DEFAULT_VOLUME: Final = "default_volume" CONF_DEFAULT_VOLUME: Final = "default_volume"
CONF_MAX_VOLUME: Final = "max_volume" CONF_MAX_VOLUME: Final = "max_volume"
CONF_VOLUME_STEP: Final = "volume_step"
CONF_SERIAL_NUMBER: Final = "serial_number" CONF_SERIAL_NUMBER: Final = "serial_number"
CONF_BEOLINK_JID: Final = "jid" CONF_BEOLINK_JID: Final = "jid"
@@ -199,57 +157,57 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
items=[ items=[
Source( Source(
id="uriStreamer", id="uriStreamer",
is_enabled=True, isEnabled=True,
is_playable=False, isPlayable=False,
name="Audio Streamer", name="Audio Streamer",
type=SourceTypeEnum(value="uriStreamer"), type=SourceTypeEnum(value="uriStreamer"),
), ),
Source( Source(
id="bluetooth", id="bluetooth",
is_enabled=True, isEnabled=True,
is_playable=False, isPlayable=False,
name="Bluetooth", name="Bluetooth",
type=SourceTypeEnum(value="bluetooth"), type=SourceTypeEnum(value="bluetooth"),
), ),
Source( Source(
id="spotify", id="spotify",
is_enabled=True, isEnabled=True,
is_playable=False, isPlayable=False,
name="Spotify Connect", name="Spotify Connect",
type=SourceTypeEnum(value="spotify"), type=SourceTypeEnum(value="spotify"),
), ),
Source( Source(
id="lineIn", id="lineIn",
is_enabled=True, isEnabled=True,
is_playable=True, isPlayable=True,
name="Line-In", name="Line-In",
type=SourceTypeEnum(value="lineIn"), type=SourceTypeEnum(value="lineIn"),
), ),
Source( Source(
id="spdif", id="spdif",
is_enabled=True, isEnabled=True,
is_playable=True, isPlayable=True,
name="Optical", name="Optical",
type=SourceTypeEnum(value="spdif"), type=SourceTypeEnum(value="spdif"),
), ),
Source( Source(
id="netRadio", id="netRadio",
is_enabled=True, isEnabled=True,
is_playable=True, isPlayable=True,
name="B&O Radio", name="B&O Radio",
type=SourceTypeEnum(value="netRadio"), type=SourceTypeEnum(value="netRadio"),
), ),
Source( Source(
id="deezer", id="deezer",
is_enabled=True, isEnabled=True,
is_playable=True, isPlayable=True,
name="Deezer", name="Deezer",
type=SourceTypeEnum(value="deezer"), type=SourceTypeEnum(value="deezer"),
), ),
Source( Source(
id="tidalConnect", id="tidalConnect",
is_enabled=True, isEnabled=True,
is_playable=True, isPlayable=True,
name="Tidal Connect", name="Tidal Connect",
type=SourceTypeEnum(value="tidalConnect"), type=SourceTypeEnum(value="tidalConnect"),
), ),
@@ -265,44 +223,3 @@ CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
# Misc. # Misc.
WEBSOCKET_CONNECTION_DELAY: Final[float] = 3.0 WEBSOCKET_CONNECTION_DELAY: Final[float] = 3.0
def get_device(hass: HomeAssistant | None, unique_id: str) -> DeviceEntry | None:
"""Get the device."""
if not isinstance(hass, HomeAssistant):
return None
device_registry = dr.async_get(hass)
device = cast(DeviceEntry, device_registry.async_get_device({(DOMAIN, unique_id)}))
return device
class BangOlufsenVariables:
"""Shared variables for various classes."""
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the object."""
# get the input from the config entry.
self.entry: ConfigEntry = entry
# Set the configuration variables.
self._host: str = self.entry.data[CONF_HOST]
self._name: str = self.entry.title
self._unique_id: str = cast(str, self.entry.unique_id)
self._client: MozartClient = MozartClient(
host=self._host,
websocket_reconnect=True,
urllib3_logging_level=logging.ERROR,
)
# Objects that get directly updated by notifications.
self._playback_metadata: PlaybackContentMetadata = PlaybackContentMetadata()
self._playback_progress: PlaybackProgress = PlaybackProgress(total_duration=0)
self._playback_source: Source = Source()
self._playback_state: RenderingState = RenderingState()
self._source_change: Source = Source()
self._volume: VolumeState = VolumeState(
level=VolumeLevel(level=0), muted=VolumeMute(muted=False)
)

View File

@@ -2,13 +2,59 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import logging
from typing import cast
from mozart_api.models import (
PlaybackContentMetadata,
PlaybackProgress,
RenderingState,
Source,
VolumeLevel,
VolumeMute,
VolumeState,
)
from mozart_api.mozart_client import MozartClient
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.const import CONF_HOST
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import CONNECTION_STATUS, DOMAIN, BangOlufsenVariables from .const import DOMAIN
class BangOlufsenVariables:
"""Shared variables for various classes."""
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the object."""
# get the input from the config entry.
self.entry: ConfigEntry = entry
self._device: DeviceEntry | None = None
# Set the configuration variables.
self._host: str = self.entry.data[CONF_HOST]
self._name: str = self.entry.title
self._unique_id: str = cast(str, self.entry.unique_id)
self._client: MozartClient = MozartClient(
host=self._host,
websocket_reconnect=True,
urllib3_logging_level=logging.ERROR,
)
# Objects that get directly updated by notifications.
self._playback_metadata: PlaybackContentMetadata = PlaybackContentMetadata()
self._playback_progress: PlaybackProgress = PlaybackProgress(totalDuration=0)
self._playback_source: Source = Source()
self._playback_state: RenderingState = RenderingState()
self._source_change: Source = Source()
self._volume: VolumeState = VolumeState(
level=VolumeLevel(level=0), muted=VolumeMute(muted=False)
)
class BangOlufsenEntity(Entity, BangOlufsenVariables): class BangOlufsenEntity(Entity, BangOlufsenVariables):
@@ -26,21 +72,6 @@ class BangOlufsenEntity(Entity, BangOlufsenVariables):
self._attr_entity_category = None self._attr_entity_category = None
self._attr_should_poll = False self._attr_should_poll = 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: async def _update_connection_state(self, connection_state: bool) -> None:
"""Update entity connection state.""" """Update entity connection state."""
self._attr_available = connection_state self._attr_available = connection_state

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bangolufsen", "documentation": "https://www.home-assistant.io/integrations/bangolufsen",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["mozart-api==3.2.1.150.1"], "requirements": ["mozart-api==3.2.1.150.4"],
"zeroconf": ["_bangolufsen._tcp.local."] "zeroconf": ["_bangolufsen._tcp.local."]
} }

View File

@@ -1,10 +1,9 @@
"""Media player entity for the Bang & Olufsen integration.""" """Media player entity for the Bang & Olufsen integration."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import timedelta
import json import json
import logging import logging
from multiprocessing.pool import ApplyResult
from typing import Any, cast from typing import Any, cast
from mozart_api import __version__ as MOZART_API_VERSION from mozart_api import __version__ as MOZART_API_VERSION
@@ -12,23 +11,17 @@ from mozart_api.exceptions import ApiException
from mozart_api.models import ( from mozart_api.models import (
Action, Action,
Art, Art,
BeolinkLeader,
BeolinkListener,
OverlayPlayRequest, OverlayPlayRequest,
PlaybackContentMetadata, PlaybackContentMetadata,
PlaybackError, PlaybackError,
PlaybackProgress, PlaybackProgress,
PlayQueueItem, PlayQueueItem,
PlayQueueItemType, PlayQueueItemType,
PlayQueueSettings,
ProductState,
RemoteMenuItem,
RenderingState, RenderingState,
SceneProperties, SceneProperties,
SoftwareUpdateState, SoftwareUpdateState,
SoftwareUpdateStatus, SoftwareUpdateStatus,
Source, Source,
SourceArray,
Uri, Uri,
UserFlow, UserFlow,
VolumeLevel, VolumeLevel,
@@ -36,6 +29,7 @@ from mozart_api.models import (
VolumeSettings, VolumeSettings,
VolumeState, VolumeState,
) )
from mozart_api.mozart_client import get_highest_resolution_artwork
from homeassistant.components import media_source from homeassistant.components import media_source
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@@ -46,35 +40,32 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
RepeatMode,
async_process_play_media_url, async_process_play_media_url,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from .const import ( from .const import (
ART_SIZE_ENUM,
BANGOLUFSEN_MEDIA_TYPE, BANGOLUFSEN_MEDIA_TYPE,
BANGOLUFSEN_STATES, BANGOLUFSEN_STATES,
CONF_BEOLINK_JID, CONF_BEOLINK_JID,
CONF_DEFAULT_VOLUME, CONF_DEFAULT_VOLUME,
CONF_MAX_VOLUME, CONF_MAX_VOLUME,
CONF_VOLUME_STEP,
DOMAIN, DOMAIN,
ENTITY_ENUM,
FALLBACK_SOURCES, FALLBACK_SOURCES,
HIDDEN_SOURCE_IDS, HIDDEN_SOURCE_IDS,
REPEAT_ENUM,
SOURCE_ENUM, SOURCE_ENUM,
VALID_MEDIA_TYPES, VALID_MEDIA_TYPES,
WEBSOCKET_NOTIFICATION, WEBSOCKET_NOTIFICATION,
) )
from .entity import BangOlufsenEntity from .entity import BangOlufsenEntity
from .util import get_device
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -86,19 +77,15 @@ BANGOLUFSEN_FEATURES = (
| MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_OFF
) )
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=2) SCAN_INTERVAL = timedelta(minutes=2)
@@ -108,9 +95,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up a Media Player entity from config entry.""" """Set up a Media Player entity from config entry."""
entity = hass.data[DOMAIN][config_entry.unique_id][ENTITY_ENUM.MEDIA_PLAYER]
# Add MediaPlayer entity # Add MediaPlayer entity
async_add_entities(new_entities=[entity], update_before_add=True) async_add_entities(
new_entities=[BangOlufsenMediaPlayer(config_entry)], update_before_add=True
)
class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity): class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
@@ -128,9 +116,7 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
self._default_volume: int = self.entry.data[CONF_DEFAULT_VOLUME] self._default_volume: int = self.entry.data[CONF_DEFAULT_VOLUME]
self._max_volume: int = self.entry.data[CONF_MAX_VOLUME] self._max_volume: int = self.entry.data[CONF_MAX_VOLUME]
self._model: str = self.entry.data[CONF_MODEL] self._model: str = self.entry.data[CONF_MODEL]
self._volume_step: int = self.entry.data[CONF_VOLUME_STEP]
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
configuration_url=f"http://{self._host}/#/", configuration_url=f"http://{self._host}/#/",
identifiers={(DOMAIN, self._unique_id)}, identifiers={(DOMAIN, self._unique_id)},
@@ -139,19 +125,16 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
name=cast(str, self.name), name=cast(str, self.name),
) )
self._attr_name = self._name self._attr_name = self._name
self._attr_should_poll = True
self._attr_unique_id = self._unique_id self._attr_unique_id = self._unique_id
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
# Misc. variables. # Misc. variables.
self._audio_sources: dict[str, str] = {} self._audio_sources: dict[str, str] = {}
self._beolink_listeners: list[BeolinkListener] = []
self._last_update: datetime = datetime(1970, 1, 1, 0, 0, 0, 0)
self._media_image: Art = Art() self._media_image: Art = Art()
self._queue_settings: PlayQueueSettings = PlayQueueSettings() # self._queue_settings: PlayQueueSettings = PlayQueueSettings()
self._remote_leader: BeolinkLeader | None = None
self._software_status: SoftwareUpdateStatus = SoftwareUpdateStatus( self._software_status: SoftwareUpdateStatus = SoftwareUpdateStatus(
software_version="", softwareVersion="",
state=SoftwareUpdateState(seconds_remaining=0, value="idle"), state=SoftwareUpdateState(secondsRemaining=0, value="idle"),
) )
self._sources: dict[str, str] = {} self._sources: dict[str, str] = {}
self._state: str = MediaPlayerState.IDLE self._state: str = MediaPlayerState.IDLE
@@ -159,66 +142,72 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers.""" """Turn on the dispatchers."""
await self._initialize() await self._initialize()
await super().async_added_to_hass() self.async_on_remove(
self._dispatchers.extend( async_dispatcher_connect(
[ self.hass,
async_dispatcher_connect( f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_ERROR}",
self.hass, self._update_playback_error,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_METADATA}", )
self._update_playback_metadata,
),
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_ERROR}",
self._update_playback_error,
),
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_PROGRESS}",
self._update_playback_progress,
),
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_STATE}",
self._update_playback_state,
),
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.SOURCE_CHANGE}",
self._update_source_change,
),
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.VOLUME}",
self._update_volume,
),
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED}",
self._update_sources,
),
]
) )
async def async_update(self) -> None: self.async_on_remove(
"""Update polling information.""" async_dispatcher_connect(
if self._attr_available: self.hass,
self._queue_settings = cast( f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_METADATA}",
ApplyResult[PlayQueueSettings], self._update_playback_metadata,
self._client.get_settings_queue(async_req=True, _request_timeout=5), )
).get() )
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_PROGRESS}",
self._update_playback_progress,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_STATE}",
self._update_playback_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED}",
self._update_sources,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.SOURCE_CHANGE}",
self._update_source_change,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.VOLUME}",
self._update_volume,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.SOFTWARE_UPDATE_STATE}",
self._update_device,
)
)
async def _initialize(self) -> None: async def _initialize(self) -> None:
"""Initialize connection dependent variables.""" """Initialize connection dependent variables."""
# Get software version. # Get software version.
self._software_status = cast( self._software_status = await self._client.get_softwareupdate_status()
ApplyResult[SoftwareUpdateStatus],
self._client.get_softwareupdate_status(async_req=True),
).get()
_LOGGER.debug( _LOGGER.debug(
"Connected to: %s %s running SW %s", "Connected to: %s %s running SW %s",
@@ -228,18 +217,15 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
) )
# Set the default and maximum volume of the product. # Set the default and maximum volume of the product.
self._client.set_volume_settings( await self._client.set_volume_settings(
volume_settings=VolumeSettings( volume_settings=VolumeSettings(
default=VolumeLevel(level=self._default_volume), default=VolumeLevel(level=self._default_volume),
maximum=VolumeLevel(level=self._max_volume), maximum=VolumeLevel(level=self._max_volume),
), )
async_req=True,
) )
# Get overall device state once. This is handled by WebSocket events the rest of the time. # Get overall device state once. This is handled by WebSocket events the rest of the time.
product_state = cast( product_state = await self._client.get_product_state()
ApplyResult[ProductState], self._client.get_product_state(async_req=True)
).get()
# Get volume information. # Get volume information.
if product_state.volume: if product_state.volume:
@@ -260,10 +246,10 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
if self._playback_state.value: if self._playback_state.value:
self._state = self._playback_state.value self._state = self._playback_state.value
self._last_update = utcnow() self._attr_media_position_updated_at = utcnow()
# Get the highest resolution available of the given images. # Get the highest resolution available of the given images.
self._update_artwork() self._media_image = get_highest_resolution_artwork(self._playback_metadata)
# If the device has been updated with new sources, then the API will fail here. # If the device has been updated with new sources, then the API will fail here.
await self._update_sources() await self._update_sources()
@@ -277,10 +263,7 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
# Audio sources # Audio sources
try: try:
# Get all available sources. # Get all available sources.
sources = cast( sources = await self._client.get_available_sources(target_remote=False)
ApplyResult[SourceArray],
self._client.get_available_sources(target_remote=False, async_req=True),
).get()
# Use a fallback list of sources # Use a fallback list of sources
except ValueError: except ValueError:
@@ -308,10 +291,7 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
} }
# Video sources from remote menu # Video sources from remote menu
menu_items = cast( menu_items = await self._client.get_remote_menu()
ApplyResult[dict[str, RemoteMenuItem]],
self._client.get_remote_menu(async_req=True),
).get()
for key in menu_items: for key in menu_items:
menu_item = menu_items[key] menu_item = menu_items[key]
@@ -337,40 +317,12 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
if self.hass.is_running: if self.hass.is_running:
self.async_write_ha_state() self.async_write_ha_state()
def _update_artwork(self) -> None:
"""Find the highest resolution image."""
# Ensure that the metadata doesn't change mid processing.
metadata = self._playback_metadata
# Check if the metadata is not null and that there is art.
if (
isinstance(metadata, PlaybackContentMetadata)
and isinstance(metadata.art, list)
and len(metadata.art) > 0
):
images = []
# Images either have a key for specifying resolution or a "size" for the image.
for image in metadata.art:
# Netradio.
if metadata.art[0].key is not None:
images.append(int(image.key.split("x")[0]))
# Everything else.
elif metadata.art[0].size is not None:
images.append(ART_SIZE_ENUM[image.size].value)
# Choose the largest image.
self._media_image = metadata.art[images.index(max(images))]
# Don't leave stale image metadata if there is no available artwork.
else:
self._media_image = Art()
async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None: async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None:
"""Update _playback_metadata and related.""" """Update _playback_metadata and related."""
self._playback_metadata = data self._playback_metadata = data
# Update current artwork. # Update current artwork.
self._update_artwork() self._media_image = get_highest_resolution_artwork(self._playback_metadata)
self.async_write_ha_state() self.async_write_ha_state()
@@ -381,7 +333,7 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
async def _update_playback_progress(self, data: PlaybackProgress) -> None: async def _update_playback_progress(self, data: PlaybackProgress) -> None:
"""Update _playback_progress and last update.""" """Update _playback_progress and last update."""
self._playback_progress = data self._playback_progress = data
self._last_update = utcnow() self._attr_media_position_updated_at = utcnow()
self.async_write_ha_state() self.async_write_ha_state()
@@ -399,12 +351,35 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
"""Update _source_change and related.""" """Update _source_change and related."""
self._source_change = data self._source_change = data
# Check if source is line-in or optical and progress should be updated
if self._source_change.id in (SOURCE_ENUM.lineIn, SOURCE_ENUM.spdif):
self._playback_progress = PlaybackProgress(progress=0)
async def _update_volume(self, data: VolumeState) -> None: async def _update_volume(self, data: VolumeState) -> None:
"""Update _volume.""" """Update _volume."""
self._volume = data self._volume = data
self.async_write_ha_state() self.async_write_ha_state()
async def _update_device(self, data: SoftwareUpdateState) -> None:
"""Update HA device SW version."""
# Get software version.
software_status = await self._client.get_softwareupdate_status()
# 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,
)
@property @property
def state(self) -> MediaPlayerState: def state(self) -> MediaPlayerState:
"""Return the current state of the media player.""" """Return the current state of the media player."""
@@ -440,15 +415,7 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
@property @property
def media_position(self) -> int | None: def media_position(self) -> int | None:
"""Return the current playback progress.""" """Return the current playback progress."""
# Don't show progress if the the device is a Beolink listener. return self._playback_progress.progress
if self._remote_leader is None:
return self._playback_progress.progress
return None
@property
def media_position_updated_at(self) -> datetime:
"""Return the last time that the playback position was updated."""
return self._last_update
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
@@ -524,59 +491,19 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
return self._source_change.name return self._source_change.name
@property
def shuffle(self) -> bool | None:
"""Return if queues should be shuffled."""
return self._queue_settings.shuffle
@property
def repeat(self) -> RepeatMode | None:
"""Return current repeat setting for queues."""
if self._queue_settings.repeat:
return cast(RepeatMode, REPEAT_ENUM(self._queue_settings.repeat).name)
return None
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Set the device to "networkStandby".""" """Set the device to "networkStandby"."""
self._client.post_standby(async_req=True) await self._client.post_standby()
async def async_volume_up(self) -> None:
"""Volume up the on media player."""
if not self._volume.level or not self._volume.level.level:
_LOGGER.warning("Error setting volume")
return
new_volume = min(self._volume.level.level + self._volume_step, self._max_volume)
self._client.set_current_volume_level(
volume_level=VolumeLevel(level=new_volume),
async_req=True,
)
async def async_volume_down(self) -> None:
"""Volume down the on media player."""
if not self._volume.level or not self._volume.level.level:
_LOGGER.warning("Error setting volume")
return
new_volume = max(self._volume.level.level - self._volume_step, 0)
self._client.set_current_volume_level(
volume_level=VolumeLevel(level=new_volume),
async_req=True,
)
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1.""" """Set volume level, range 0..1."""
self._client.set_current_volume_level( await self._client.set_current_volume_level(
volume_level=VolumeLevel(level=int(volume * 100)), volume_level=VolumeLevel(level=int(volume * 100))
async_req=True,
) )
async def async_mute_volume(self, mute: bool) -> None: async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute media player.""" """Mute or unmute media player."""
self._client.set_volume_mute( await self._client.set_volume_mute(volume_mute=VolumeMute(muted=mute))
volume_mute=VolumeMute(muted=mute),
async_req=True,
)
async def async_media_play_pause(self) -> None: async def async_media_play_pause(self) -> None:
"""Toggle play/pause media player.""" """Toggle play/pause media player."""
@@ -587,28 +514,26 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
async def async_media_pause(self) -> None: async def async_media_pause(self) -> None:
"""Pause media player.""" """Pause media player."""
self._client.post_playback_command(command="pause", async_req=True) await self._client.post_playback_command(command="pause")
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
"""Play media player.""" """Play media player."""
self._client.post_playback_command(command="play", async_req=True) await self._client.post_playback_command(command="play")
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
"""Pause media player.""" """Pause media player."""
self._client.post_playback_command(command="stop", async_req=True) await self._client.post_playback_command(command="stop")
async def async_media_next_track(self) -> None: async def async_media_next_track(self) -> None:
"""Send the next track command.""" """Send the next track command."""
self._client.post_playback_command(command="skip", async_req=True) await self._client.post_playback_command(command="skip")
async def async_media_seek(self, position: float) -> None: async def async_media_seek(self, position: float) -> None:
"""Seek to position in ms.""" """Seek to position in ms."""
if self.source == SOURCE_ENUM.deezer: if self.source == SOURCE_ENUM.deezer:
self._client.seek_to_position( await self._client.seek_to_position(position_ms=int(position * 1000))
position_ms=int(position * 1000), async_req=True
)
# Try to prevent the playback progress from bouncing in the UI. # Try to prevent the playback progress from bouncing in the UI.
self._last_update = utcnow() self._attr_media_position_updated_at = utcnow()
self._playback_progress = PlaybackProgress(progress=int(position)) self._playback_progress = PlaybackProgress(progress=int(position))
self.async_write_ha_state() self.async_write_ha_state()
@@ -617,28 +542,11 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
async def async_media_previous_track(self) -> None: async def async_media_previous_track(self) -> None:
"""Send the previous track command.""" """Send the previous track command."""
self._client.post_playback_command(command="prev", async_req=True) await self._client.post_playback_command(command="prev")
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
"""Clear the current playback queue.""" """Clear the current playback queue."""
self._client.post_clear_queue(async_req=True) await self._client.post_clear_queue()
async def async_set_shuffle(self, shuffle: bool) -> None:
"""Set playback queues to shuffle."""
self._client.set_settings_queue(
play_queue_settings=PlayQueueSettings(shuffle=shuffle),
async_req=True,
)
self._queue_settings.shuffle = shuffle
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set playback queues to repeat."""
self._client.set_settings_queue(
play_queue_settings=PlayQueueSettings(repeat=REPEAT_ENUM[repeat]),
async_req=True,
)
self._queue_settings.repeat = REPEAT_ENUM[repeat]
async def async_select_source(self, source: str) -> None: async def async_select_source(self, source: str) -> None:
"""Select an input source.""" """Select an input source."""
@@ -656,10 +564,10 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
# Check for source type # Check for source type
if source in self._audio_sources.values(): if source in self._audio_sources.values():
# Audio # Audio
self._client.set_active_source(source_id=key, async_req=True) await self._client.set_active_source(source_id=key)
else: else:
# Video # Video
self._client.post_remote_trigger(id=key, async_req=True) await self._client.post_remote_trigger(id=key)
async def async_play_media( async def async_play_media(
self, self,
@@ -693,33 +601,31 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
media_id = media_id.replace(".m3u", "") media_id = media_id.replace(".m3u", "")
if media_type in (MediaType.URL, MediaType.MUSIC): if media_type in (MediaType.URL, MediaType.MUSIC):
self._client.post_uri_source(uri=Uri(location=media_id), async_req=True) await self._client.post_uri_source(uri=Uri(location=media_id))
# The "provider" media_type may not be suitable for overlay all the time. # The "provider" media_type may not be suitable for overlay all the time.
# Use it for now. # Use it for now.
elif media_type == BANGOLUFSEN_MEDIA_TYPE.TTS: elif media_type == BANGOLUFSEN_MEDIA_TYPE.TTS:
self._client.post_overlay_play( await self._client.post_overlay_play(
overlay_play_request=OverlayPlayRequest( overlay_play_request=OverlayPlayRequest(
uri=Uri(location=media_id), uri=Uri(location=media_id),
), )
async_req=True,
) )
elif media_type == BANGOLUFSEN_MEDIA_TYPE.RADIO: elif media_type == BANGOLUFSEN_MEDIA_TYPE.RADIO:
self._client.run_provided_scene( await self._client.run_provided_scene(
scene_properties=SceneProperties( scene_properties=SceneProperties(
action_list=[ actionList=[
Action( Action(
type="radio", type="radio",
radio_station_id=media_id, radioStationId=media_id,
) )
] ]
), )
async_req=True,
) )
elif media_type == BANGOLUFSEN_MEDIA_TYPE.FAVOURITE: elif media_type == BANGOLUFSEN_MEDIA_TYPE.FAVOURITE:
self._client.activate_preset(id=int(media_id), async_req=True) await self._client.activate_preset(id=int(media_id))
elif media_type == BANGOLUFSEN_MEDIA_TYPE.DEEZER: elif media_type == BANGOLUFSEN_MEDIA_TYPE.DEEZER:
try: try:
@@ -730,8 +636,8 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
deezer_id = kwargs[ATTR_MEDIA_EXTRA]["id"] deezer_id = kwargs[ATTR_MEDIA_EXTRA]["id"]
# Play Deezer flow. # Play Deezer flow.
self._client.start_deezer_flow( await self._client.start_deezer_flow(
user_flow=UserFlow(user_id=deezer_id), async_req=True user_flow=UserFlow(userId=deezer_id)
) )
# Play a Deezer playlist or album. # Play a Deezer playlist or album.
@@ -740,26 +646,24 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity):
if "start_from" in kwargs[ATTR_MEDIA_EXTRA]: if "start_from" in kwargs[ATTR_MEDIA_EXTRA]:
start_from = kwargs[ATTR_MEDIA_EXTRA]["start_from"] start_from = kwargs[ATTR_MEDIA_EXTRA]["start_from"]
self._client.add_to_queue( await self._client.add_to_queue(
play_queue_item=PlayQueueItem( play_queue_item=PlayQueueItem(
provider=PlayQueueItemType(value="deezer"), provider=PlayQueueItemType(value="deezer"),
start_now_from_position=start_from, startNowFromPosition=start_from,
type="playlist", type="playlist",
uri=media_id, uri=media_id,
), )
async_req=True,
) )
# Play a Deezer track. # Play a Deezer track.
else: else:
self._client.add_to_queue( await self._client.add_to_queue(
play_queue_item=PlayQueueItem( play_queue_item=PlayQueueItem(
provider=PlayQueueItemType(value="deezer"), provider=PlayQueueItemType(value="deezer"),
start_now_from_position=0, startNowFromPosition=0,
type="track", type="track",
uri=media_id, uri=media_id,
), )
async_req=True,
) )
except ApiException as error: except ApiException as error:

View File

@@ -0,0 +1,21 @@
"""Various utilities for the Bang & Olufsen integration."""
from __future__ import annotations
from typing import cast
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN
def get_device(hass: HomeAssistant | None, unique_id: str) -> DeviceEntry | None:
"""Get the device."""
if not isinstance(hass, HomeAssistant):
return None
device_registry = dr.async_get(hass)
device = cast(DeviceEntry, device_registry.async_get_device({(DOMAIN, unique_id)}))
return device

View File

@@ -5,8 +5,6 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
import logging import logging
from multiprocessing.pool import ApplyResult
from typing import cast
from mozart_api.models import ( from mozart_api.models import (
PlaybackContentMetadata, PlaybackContentMetadata,
@@ -14,7 +12,6 @@ from mozart_api.models import (
PlaybackProgress, PlaybackProgress,
RenderingState, RenderingState,
SoftwareUpdateState, SoftwareUpdateState,
SoftwareUpdateStatus,
Source, Source,
VolumeState, VolumeState,
WebsocketNotificationTag, WebsocketNotificationTag,
@@ -22,7 +19,6 @@ from mozart_api.models import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -30,9 +26,9 @@ from .const import (
BANGOLUFSEN_WEBSOCKET_EVENT, BANGOLUFSEN_WEBSOCKET_EVENT,
CONNECTION_STATUS, CONNECTION_STATUS,
WEBSOCKET_NOTIFICATION, WEBSOCKET_NOTIFICATION,
BangOlufsenVariables,
get_device,
) )
from .entity import BangOlufsenVariables
from .util import get_device
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -46,7 +42,6 @@ class BangOlufsenWebsocket(BangOlufsenVariables):
BangOlufsenVariables.__init__(self, entry) BangOlufsenVariables.__init__(self, entry)
self.hass = hass self.hass = hass
self._device: DeviceEntry | None = None
# WebSocket callbacks # WebSocket callbacks
self._client.get_on_connection(self.on_connection) self._client.get_on_connection(self.on_connection)
@@ -165,27 +160,12 @@ class BangOlufsenWebsocket(BangOlufsenVariables):
) )
def on_software_update_state(self, notification: SoftwareUpdateState) -> None: def on_software_update_state(self, notification: SoftwareUpdateState) -> None:
"""Check device sw version.""" """Send software_update_state dispatch."""
async_dispatcher_send(
# Get software version. self.hass,
software_status = cast( f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.SOFTWARE_UPDATE_STATE}",
ApplyResult[SoftwareUpdateStatus], notification,
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: def on_all_notifications_raw(self, notification: dict) -> None:
"""Receive all notifications.""" """Receive all notifications."""

View File

@@ -1270,7 +1270,7 @@ motionblinds==0.6.18
motioneye-client==0.3.14 motioneye-client==0.3.14
# homeassistant.components.bangolufsen # homeassistant.components.bangolufsen
mozart-api==3.2.1.150.1 mozart-api==3.2.1.150.4
# homeassistant.components.mullvad # homeassistant.components.mullvad
mullvad-api==1.0.0 mullvad-api==1.0.0

View File

@@ -994,7 +994,7 @@ motionblinds==0.6.18
motioneye-client==0.3.14 motioneye-client==0.3.14
# homeassistant.components.bangolufsen # homeassistant.components.bangolufsen
mozart-api==3.2.1.150.1 mozart-api==3.2.1.150.4
# homeassistant.components.mullvad # homeassistant.components.mullvad
mullvad-api==1.0.0 mullvad-api==1.0.0

View File

@@ -1,6 +1,6 @@
"""Test fixtures for bangolufsen.""" """Test fixtures for bangolufsen."""
from unittest.mock import Mock, patch from unittest.mock import AsyncMock, patch
from mozart_api.models import BeolinkPeer, VolumeLevel, VolumeSettings from mozart_api.models import BeolinkPeer, VolumeLevel, VolumeSettings
import pytest import pytest
@@ -34,11 +34,11 @@ class MockMozartClient:
) )
# API endpoints # API endpoints
get_beolink_self = Mock() get_beolink_self = AsyncMock()
get_beolink_self.return_value.get.return_value = get_beolink_self_result get_beolink_self.return_value = get_beolink_self_result
get_volume_settings = Mock() get_volume_settings = AsyncMock()
get_volume_settings.return_value.get.return_value = get_volume_settings_result get_volume_settings.return_value = get_volume_settings_result
@pytest.fixture @pytest.fixture

View File

@@ -1,6 +1,8 @@
"""Constants used for testing the bangolufsen integration.""" """Constants used for testing the bangolufsen integration."""
from ipaddress import IPv4Address
from homeassistant.components.bangolufsen.const import ( from homeassistant.components.bangolufsen.const import (
ATTR_FRIENDLY_NAME, ATTR_FRIENDLY_NAME,
ATTR_ITEM_NUMBER, ATTR_ITEM_NUMBER,
@@ -9,10 +11,9 @@ from homeassistant.components.bangolufsen.const import (
CONF_BEOLINK_JID, CONF_BEOLINK_JID,
CONF_DEFAULT_VOLUME, CONF_DEFAULT_VOLUME,
CONF_MAX_VOLUME, CONF_MAX_VOLUME,
CONF_VOLUME_STEP,
) )
from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.const import CONF_HOST, CONF_MODEL
TEST_HOST = "192.168.0.1" TEST_HOST = "192.168.0.1"
TEST_HOST_INVALID = "192.168.0" TEST_HOST_INVALID = "192.168.0"
@@ -48,14 +49,12 @@ TEST_DATA_USER = {CONF_HOST: TEST_HOST, CONF_MODEL: TEST_MODEL_BALANCE}
TEST_DATA_USER_INVALID = {CONF_HOST: TEST_HOST_INVALID, CONF_MODEL: TEST_MODEL_BALANCE} TEST_DATA_USER_INVALID = {CONF_HOST: TEST_HOST_INVALID, CONF_MODEL: TEST_MODEL_BALANCE}
TEST_DATA_NO_HOST = { TEST_DATA_NO_HOST = {
CONF_VOLUME_STEP: TEST_VOLUME_STEP,
CONF_DEFAULT_VOLUME: TEST_DEFAULT_VOLUME, CONF_DEFAULT_VOLUME: TEST_DEFAULT_VOLUME,
CONF_MAX_VOLUME: TEST_MAX_VOLUME, CONF_MAX_VOLUME: TEST_MAX_VOLUME,
} }
TEST_DATA_CONFIRM = { TEST_DATA_CONFIRM = {
CONF_HOST: TEST_HOST, CONF_HOST: TEST_HOST,
CONF_VOLUME_STEP: TEST_VOLUME_STEP,
CONF_DEFAULT_VOLUME: TEST_DEFAULT_VOLUME, CONF_DEFAULT_VOLUME: TEST_DEFAULT_VOLUME,
CONF_MAX_VOLUME: TEST_MAX_VOLUME, CONF_MAX_VOLUME: TEST_MAX_VOLUME,
CONF_MODEL: TEST_MODEL_BALANCE, CONF_MODEL: TEST_MODEL_BALANCE,
@@ -63,8 +62,8 @@ TEST_DATA_CONFIRM = {
} }
TEST_DATA_ZEROCONF = ZeroconfServiceInfo( TEST_DATA_ZEROCONF = ZeroconfServiceInfo(
addresses=[TEST_HOST], ip_address=IPv4Address(TEST_HOST),
host=TEST_HOST, ip_addresses=[IPv4Address(TEST_HOST)],
port=80, port=80,
hostname=TEST_HOSTNAME_ZEROCONF, hostname=TEST_HOSTNAME_ZEROCONF,
type=TEST_TYPE_ZEROCONF, type=TEST_TYPE_ZEROCONF,
@@ -78,26 +77,11 @@ TEST_DATA_ZEROCONF = ZeroconfServiceInfo(
) )
TEST_DATA_ZEROCONF_NOT_MOZART = ZeroconfServiceInfo( TEST_DATA_ZEROCONF_NOT_MOZART = ZeroconfServiceInfo(
addresses=[TEST_HOST], ip_address=IPv4Address(TEST_HOST),
host=TEST_HOST, ip_addresses=[IPv4Address(TEST_HOST)],
port=80, port=80,
hostname=TEST_HOSTNAME_ZEROCONF, hostname=TEST_HOSTNAME_ZEROCONF,
type=TEST_TYPE_ZEROCONF, type=TEST_TYPE_ZEROCONF,
name=TEST_NAME_ZEROCONF, name=TEST_NAME_ZEROCONF,
properties={ATTR_SERIAL_NUMBER: TEST_SERIAL_NUMBER}, properties={ATTR_SERIAL_NUMBER: TEST_SERIAL_NUMBER},
) )
TEST_DATA_OPTIONS = {
CONF_NAME: TEST_NAME_OPTIONS,
CONF_VOLUME_STEP: TEST_VOLUME_STEP_OPTIONS,
CONF_DEFAULT_VOLUME: TEST_DEFAULT_VOLUME_OPTIONS,
CONF_MAX_VOLUME: TEST_MAX_VOLUME_OPTIONS,
}
TEST_DATA_OPTIONS_FULL = {
CONF_HOST: TEST_HOST,
CONF_VOLUME_STEP: TEST_VOLUME_STEP_OPTIONS,
CONF_DEFAULT_VOLUME: TEST_DEFAULT_VOLUME_OPTIONS,
CONF_MAX_VOLUME: TEST_MAX_VOLUME_OPTIONS,
CONF_MODEL: TEST_MODEL_BALANCE,
CONF_BEOLINK_JID: TEST_JID_1,
}

View File

@@ -1,6 +1,8 @@
"""Test the bangolufsen config_flow.""" """Test the bangolufsen config_flow."""
from unittest.mock import Mock
from mozart_api.exceptions import ApiException, NotFoundException from mozart_api.exceptions import ApiException, NotFoundException
import pytest import pytest
from urllib3.exceptions import MaxRetryError, NewConnectionError from urllib3.exceptions import MaxRetryError, NewConnectionError
@@ -14,7 +16,6 @@ from homeassistant.data_entry_flow import FlowResultType
from .conftest import MockMozartClient from .conftest import MockMozartClient
from .const import ( from .const import (
TEST_DATA_CONFIRM, TEST_DATA_CONFIRM,
TEST_DATA_OPTIONS,
TEST_DATA_USER, TEST_DATA_USER,
TEST_DATA_USER_INVALID, TEST_DATA_USER_INVALID,
TEST_DATA_ZEROCONF, TEST_DATA_ZEROCONF,
@@ -28,7 +29,7 @@ async def test_config_flow_max_retry_error(
hass: HomeAssistant, mock_client: MockMozartClient hass: HomeAssistant, mock_client: MockMozartClient
) -> None: ) -> None:
"""Test we handle not_mozart_device.""" """Test we handle not_mozart_device."""
mock_client.get_beolink_self.side_effect = MaxRetryError(pool=None, url=None) mock_client.get_beolink_self.side_effect = MaxRetryError(pool=Mock(), url="")
result_user = await hass.config_entries.flow.async_init( result_user = await hass.config_entries.flow.async_init(
handler=DOMAIN, handler=DOMAIN,
@@ -64,9 +65,7 @@ async def test_config_flow_new_connection_error(
hass: HomeAssistant, mock_client: MockMozartClient hass: HomeAssistant, mock_client: MockMozartClient
) -> None: ) -> None:
"""Test we handle new_connection_error.""" """Test we handle new_connection_error."""
mock_client.get_beolink_self.side_effect = NewConnectionError( mock_client.get_beolink_self.side_effect = NewConnectionError(Mock(), "")
pool=None, message=None
)
result_user = await hass.config_entries.flow.async_init( result_user = await hass.config_entries.flow.async_init(
handler=DOMAIN, handler=DOMAIN,
@@ -184,26 +183,26 @@ async def test_config_flow_zeroconf_not_mozart_device(hass: HomeAssistant) -> No
assert result_user["reason"] == "not_mozart_device" assert result_user["reason"] == "not_mozart_device"
async def test_config_flow_options(hass: HomeAssistant, mock_config_entry) -> None: # async def test_config_flow_options(hass: HomeAssistant, mock_config_entry) -> None:
"""Test config flow options.""" # """Test config flow options."""
mock_config_entry.add_to_hass(hass) # mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id) # assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
result_user = await hass.config_entries.options.async_init( # result_user = await hass.config_entries.options.async_init(
mock_config_entry.entry_id # mock_config_entry.entry_id
) # )
assert result_user["type"] == FlowResultType.FORM # assert result_user["type"] == FlowResultType.FORM
assert result_user["step_id"] == "init" # assert result_user["step_id"] == "init"
result_confirm = await hass.config_entries.options.async_configure( # result_confirm = await hass.config_entries.options.async_configure(
flow_id=result_user["flow_id"], # flow_id=result_user["flow_id"],
user_input=TEST_DATA_OPTIONS, # user_input=TEST_DATA_OPTIONS,
) # )
assert result_confirm["type"] == FlowResultType.CREATE_ENTRY # assert result_confirm["type"] == FlowResultType.CREATE_ENTRY
new_data = TEST_DATA_CONFIRM # new_data = TEST_DATA_CONFIRM
new_data.update(TEST_DATA_OPTIONS) # new_data.update(TEST_DATA_OPTIONS)
assert result_confirm["data"] == new_data # assert result_confirm["data"] == new_data