Skip to content

Commit

Permalink
Add bang_olufsen integration (#93462)
Browse files Browse the repository at this point in the history
* 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
mj23000 authored Jan 24, 2024
1 parent 393dee1 commit 1d7e0e7
Show file tree
Hide file tree
Showing 22 changed files with 1,790 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ omit =
homeassistant/components/baf/sensor.py
homeassistant/components/baf/switch.py
homeassistant/components/baidu/tts.py
homeassistant/components/bang_olufsen/__init__.py
homeassistant/components/bang_olufsen/const.py
homeassistant/components/bang_olufsen/entity.py
homeassistant/components/bang_olufsen/media_player.py
homeassistant/components/bang_olufsen/util.py
homeassistant/components/bang_olufsen/websocket.py
homeassistant/components/bbox/device_tracker.py
homeassistant/components/bbox/sensor.py
homeassistant/components/beewi_smartclim/sensor.py
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ homeassistant.components.awair.*
homeassistant.components.axis.*
homeassistant.components.backup.*
homeassistant.components.baf.*
homeassistant.components.bang_olufsen.*
homeassistant.components.bayesian.*
homeassistant.components.binary_sensor.*
homeassistant.components.bitcoin.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ build.json @home-assistant/supervisor
/tests/components/baf/ @bdraco @jfroy
/homeassistant/components/balboa/ @garbled1 @natekspencer
/tests/components/balboa/ @garbled1 @natekspencer
/homeassistant/components/bang_olufsen/ @mj23000
/tests/components/bang_olufsen/ @mj23000
/homeassistant/components/bayesian/ @HarvsG
/tests/components/bayesian/ @HarvsG
/homeassistant/components/beewi_smartclim/ @alemuro
Expand Down
85 changes: 85 additions & 0 deletions homeassistant/components/bang_olufsen/__init__.py
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
184 changes: 184 additions & 0 deletions homeassistant/components/bang_olufsen/config_flow.py
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,
)
Loading

0 comments on commit 1d7e0e7

Please sign in to comment.