-
-
Notifications
You must be signed in to change notification settings - Fork 31.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add bang_olufsen integration (#93462)
* Add bangolufsen integration * add untested files to .coveragerc * Simplify integration to media_player platform * Remove missing files from .coveragerc * Add beolink_set_relative_volume custom service Tweaks * Remove custom services Remove grouping as it was dependent on custom services * Update API to 3.2.1.150.0 Reduce and optimize code with feedback from joostlek Tweaks * Updated testing * Remove unused options schema * Fix bugfix setting wrong state * Fix wrong initial state * Bump API * Fix Beosound Level not reconnecting properly * Remove unused constant * Fix wrong variable checked to determine source * Update integration with feedback from emontnemery * Update integration with feedback from emontnemery * Remove unused code * Move API client into dataclass Fix not all config_flow exceptions caught Tweaks * Add Bang & Olufsen brand * Revert "Add Bang & Olufsen brand" This reverts commit 57b2722. * Remove volume options from setup Simplify device checks rename integration to bang_olufsen update tests to pass Update API * Remove _device from base Add _device to websocket * Move SW version device update to websocket Sort websocket variables * Add WebSocket connection test * Remove unused constants * Remove confirmation form Make discovered devices get added to Home Assistant immediately Fix device not being available on mdns discovery Change config flow aborts to forms with error * Update tests for new config_flow Add missing api_exception test * Restrict manual and discovered IP addresses to IPv4 * Re-add confirmation step for zeroconf discovery Improve error messages Move exception mapping dict to module level * Enable remote control WebSocket listener * Update tests
- Loading branch information
Showing
22 changed files
with
1,790 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
"""The Bang & Olufsen integration.""" | ||
from __future__ import annotations | ||
|
||
from dataclasses import dataclass | ||
|
||
from aiohttp.client_exceptions import ClientConnectorError | ||
from mozart_api.exceptions import ApiException | ||
from mozart_api.mozart_client import MozartClient | ||
|
||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.const import CONF_HOST, CONF_MODEL, Platform | ||
from homeassistant.core import HomeAssistant | ||
from homeassistant.exceptions import ConfigEntryNotReady | ||
import homeassistant.helpers.device_registry as dr | ||
|
||
from .const import DOMAIN | ||
from .websocket import BangOlufsenWebsocket | ||
|
||
|
||
@dataclass | ||
class BangOlufsenData: | ||
"""Dataclass for API client and WebSocket client.""" | ||
|
||
websocket: BangOlufsenWebsocket | ||
client: MozartClient | ||
|
||
|
||
PLATFORMS = [Platform.MEDIA_PLAYER] | ||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Set up from a config entry.""" | ||
|
||
# Remove casts to str | ||
assert entry.unique_id | ||
|
||
# Create device now as BangOlufsenWebsocket needs a device for debug logging, firing events etc. | ||
device_registry = dr.async_get(hass) | ||
device_registry.async_get_or_create( | ||
config_entry_id=entry.entry_id, | ||
identifiers={(DOMAIN, entry.unique_id)}, | ||
name=entry.title, | ||
model=entry.data[CONF_MODEL], | ||
) | ||
|
||
client = MozartClient(host=entry.data[CONF_HOST], websocket_reconnect=True) | ||
|
||
# Check connection and try to initialize it. | ||
try: | ||
await client.get_battery_state(_request_timeout=3) | ||
except (ApiException, ClientConnectorError, TimeoutError) as error: | ||
await client.close_api_client() | ||
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error | ||
|
||
websocket = BangOlufsenWebsocket(hass, entry, client) | ||
|
||
# Add the websocket and API client | ||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BangOlufsenData( | ||
websocket, | ||
client, | ||
) | ||
|
||
# Check and start WebSocket connection | ||
if not await client.connect_notifications(remote_control=True): | ||
raise ConfigEntryNotReady( | ||
f"Unable to connect to {entry.title} WebSocket notification channel" | ||
) | ||
|
||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
|
||
return True | ||
|
||
|
||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Unload a config entry.""" | ||
# Close the API client and WebSocket notification listener | ||
hass.data[DOMAIN][entry.entry_id].client.disconnect_notifications() | ||
await hass.data[DOMAIN][entry.entry_id].client.close_api_client() | ||
|
||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
|
||
if unload_ok: | ||
hass.data[DOMAIN].pop(entry.entry_id) | ||
|
||
return unload_ok |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
"""Config flow for the Bang & Olufsen integration.""" | ||
from __future__ import annotations | ||
|
||
from ipaddress import AddressValueError, IPv4Address | ||
from typing import Any, TypedDict | ||
|
||
from aiohttp.client_exceptions import ClientConnectorError | ||
from mozart_api.exceptions import ApiException | ||
from mozart_api.mozart_client import MozartClient | ||
import voluptuous as vol | ||
|
||
from homeassistant.components.zeroconf import ZeroconfServiceInfo | ||
from homeassistant.config_entries import ConfigFlow | ||
from homeassistant.const import CONF_HOST, CONF_MODEL | ||
from homeassistant.data_entry_flow import FlowResult | ||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig | ||
|
||
from .const import ( | ||
ATTR_FRIENDLY_NAME, | ||
ATTR_ITEM_NUMBER, | ||
ATTR_SERIAL_NUMBER, | ||
ATTR_TYPE_NUMBER, | ||
COMPATIBLE_MODELS, | ||
CONF_SERIAL_NUMBER, | ||
DEFAULT_MODEL, | ||
DOMAIN, | ||
) | ||
|
||
|
||
class EntryData(TypedDict, total=False): | ||
"""TypedDict for config_entry data.""" | ||
|
||
host: str | ||
jid: str | ||
model: str | ||
name: str | ||
|
||
|
||
# Map exception types to strings | ||
_exception_map = { | ||
ApiException: "api_exception", | ||
ClientConnectorError: "client_connector_error", | ||
TimeoutError: "timeout_error", | ||
AddressValueError: "invalid_ip", | ||
} | ||
|
||
|
||
class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN): | ||
"""Handle a config flow.""" | ||
|
||
_beolink_jid = "" | ||
_client: MozartClient | ||
_host = "" | ||
_model = "" | ||
_name = "" | ||
_serial_number = "" | ||
|
||
def __init__(self) -> None: | ||
"""Init the config flow.""" | ||
|
||
VERSION = 1 | ||
|
||
async def async_step_user( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> FlowResult: | ||
"""Handle the initial step.""" | ||
data_schema = vol.Schema( | ||
{ | ||
vol.Required(CONF_HOST): str, | ||
vol.Required(CONF_MODEL, default=DEFAULT_MODEL): SelectSelector( | ||
SelectSelectorConfig(options=COMPATIBLE_MODELS) | ||
), | ||
} | ||
) | ||
|
||
if user_input is not None: | ||
self._host = user_input[CONF_HOST] | ||
self._model = user_input[CONF_MODEL] | ||
|
||
# Check if the IP address is a valid IPv4 address. | ||
try: | ||
IPv4Address(self._host) | ||
except AddressValueError as error: | ||
return self.async_show_form( | ||
step_id="user", | ||
data_schema=data_schema, | ||
errors={"base": _exception_map[type(error)]}, | ||
) | ||
|
||
self._client = MozartClient(self._host) | ||
|
||
# Try to get information from Beolink self method. | ||
async with self._client: | ||
try: | ||
beolink_self = await self._client.get_beolink_self( | ||
_request_timeout=3 | ||
) | ||
except ( | ||
ApiException, | ||
ClientConnectorError, | ||
TimeoutError, | ||
) as error: | ||
return self.async_show_form( | ||
step_id="user", | ||
data_schema=data_schema, | ||
errors={"base": _exception_map[type(error)]}, | ||
) | ||
|
||
self._beolink_jid = beolink_self.jid | ||
self._serial_number = beolink_self.jid.split(".")[2].split("@")[0] | ||
|
||
await self.async_set_unique_id(self._serial_number) | ||
self._abort_if_unique_id_configured() | ||
|
||
return await self._create_entry() | ||
|
||
return self.async_show_form( | ||
step_id="user", | ||
data_schema=data_schema, | ||
) | ||
|
||
async def async_step_zeroconf( | ||
self, discovery_info: ZeroconfServiceInfo | ||
) -> FlowResult: | ||
"""Handle discovery using Zeroconf.""" | ||
|
||
# Check if the discovered device is a Mozart device | ||
if ATTR_FRIENDLY_NAME not in discovery_info.properties: | ||
return self.async_abort(reason="not_mozart_device") | ||
|
||
# Ensure that an IPv4 address is received | ||
self._host = discovery_info.host | ||
try: | ||
IPv4Address(self._host) | ||
except AddressValueError: | ||
return self.async_abort(reason="ipv6_address") | ||
|
||
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" | ||
|
||
await self.async_set_unique_id(self._serial_number) | ||
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) | ||
|
||
# Set the discovered device title | ||
self.context["title_placeholders"] = { | ||
"name": discovery_info.properties[ATTR_FRIENDLY_NAME] | ||
} | ||
|
||
return await self.async_step_zeroconf_confirm() | ||
|
||
async def _create_entry(self) -> FlowResult: | ||
"""Create the config entry for a discovered or manually configured Bang & Olufsen device.""" | ||
# Ensure that created entities have a unique and easily identifiable id and not a "friendly name" | ||
self._name = f"{self._model}-{self._serial_number}" | ||
|
||
return self.async_create_entry( | ||
title=self._name, | ||
data=EntryData( | ||
host=self._host, | ||
jid=self._beolink_jid, | ||
model=self._model, | ||
name=self._name, | ||
), | ||
) | ||
|
||
async def async_step_zeroconf_confirm( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> FlowResult: | ||
"""Confirm the configuration of the device.""" | ||
if user_input is not None: | ||
return await self._create_entry() | ||
|
||
self._set_confirm_only() | ||
|
||
return self.async_show_form( | ||
step_id="zeroconf_confirm", | ||
description_placeholders={ | ||
CONF_HOST: self._host, | ||
CONF_MODEL: self._model, | ||
CONF_SERIAL_NUMBER: self._serial_number, | ||
}, | ||
last_step=True, | ||
) |
Oops, something went wrong.