mirror of
https://github.com/home-assistant/core.git
synced 2025-07-17 18:27:09 +00:00
Add Cambridge Audio integration (#125642)
* Add Cambridge Audio integration * Add zeroconf discovery to Cambridge Audio * Bump aiostreammagic to 2.0.1 * Bump aiostreammagic to 2.0.3 * Add tests to Cambridge Audio * Fix package names for Cambridge Audio * Removed unnecessary mock from Cambridge Audio tests * Clean up Cambridge Audio integration * Add additional zeroconf tests for Cambridge Audio * Update tests/components/cambridge_audio/test_config_flow.py --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
parent
457cb7ace0
commit
650c92a3cf
@ -238,6 +238,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/button/ @home-assistant/core
|
/tests/components/button/ @home-assistant/core
|
||||||
/homeassistant/components/calendar/ @home-assistant/core
|
/homeassistant/components/calendar/ @home-assistant/core
|
||||||
/tests/components/calendar/ @home-assistant/core
|
/tests/components/calendar/ @home-assistant/core
|
||||||
|
/homeassistant/components/cambridge_audio/ @noahhusby
|
||||||
|
/tests/components/cambridge_audio/ @noahhusby
|
||||||
/homeassistant/components/camera/ @home-assistant/core
|
/homeassistant/components/camera/ @home-assistant/core
|
||||||
/tests/components/camera/ @home-assistant/core
|
/tests/components/camera/ @home-assistant/core
|
||||||
/homeassistant/components/cast/ @emontnemery
|
/homeassistant/components/cast/ @emontnemery
|
||||||
|
46
homeassistant/components/cambridge_audio/__init__.py
Normal file
46
homeassistant/components/cambridge_audio/__init__.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""The Cambridge Audio integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from aiostreammagic import StreamMagicClient
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST, Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
|
from .const import CONNECT_TIMEOUT, STREAM_MAGIC_EXCEPTIONS
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
|
||||||
|
|
||||||
|
type CambridgeAudioConfigEntry = ConfigEntry[StreamMagicClient]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: CambridgeAudioConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Set up Cambridge Audio integration from a config entry."""
|
||||||
|
|
||||||
|
client = StreamMagicClient(entry.data[CONF_HOST])
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(CONNECT_TIMEOUT):
|
||||||
|
await client.connect()
|
||||||
|
except STREAM_MAGIC_EXCEPTIONS as err:
|
||||||
|
raise ConfigEntryNotReady(f"Error while connecting to {client.host}") from err
|
||||||
|
entry.runtime_data = client
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(
|
||||||
|
hass: HomeAssistant, entry: CambridgeAudioConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
await entry.runtime_data.disconnect()
|
||||||
|
return unload_ok
|
93
homeassistant/components/cambridge_audio/config_flow.py
Normal file
93
homeassistant/components/cambridge_audio/config_flow.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""Config flow for Cambridge Audio."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiostreammagic import StreamMagicClient
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import zeroconf
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||||
|
|
||||||
|
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Cambridge Audio configuration flow."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the config flow."""
|
||||||
|
self.data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def async_step_zeroconf(
|
||||||
|
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle zeroconf discovery."""
|
||||||
|
self.data[CONF_HOST] = host = discovery_info.host
|
||||||
|
|
||||||
|
await self.async_set_unique_id(discovery_info.properties["serial"])
|
||||||
|
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||||
|
client = StreamMagicClient(host)
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(CONNECT_TIMEOUT):
|
||||||
|
await client.connect()
|
||||||
|
except STREAM_MAGIC_EXCEPTIONS:
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
|
||||||
|
self.data[CONF_NAME] = client.info.name
|
||||||
|
|
||||||
|
self.context["title_placeholders"] = {
|
||||||
|
"name": self.data[CONF_NAME],
|
||||||
|
}
|
||||||
|
await client.disconnect()
|
||||||
|
return await self.async_step_discovery_confirm()
|
||||||
|
|
||||||
|
async def async_step_discovery_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Confirm discovery."""
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self.data[CONF_NAME],
|
||||||
|
data={CONF_HOST: self.data[CONF_HOST]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_confirm_only()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="discovery_confirm",
|
||||||
|
description_placeholders={
|
||||||
|
"name": self.data[CONF_NAME],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle a flow initialized by the user."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input:
|
||||||
|
client = StreamMagicClient(user_input[CONF_HOST])
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(CONNECT_TIMEOUT):
|
||||||
|
await client.connect()
|
||||||
|
except STREAM_MAGIC_EXCEPTIONS:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(
|
||||||
|
client.info.unit_id, raise_on_progress=False
|
||||||
|
)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=client.info.name,
|
||||||
|
data={CONF_HOST: user_input[CONF_HOST]},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.disconnect()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
19
homeassistant/components/cambridge_audio/const.py
Normal file
19
homeassistant/components/cambridge_audio/const.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""Constants for the Cambridge Audio integration."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiostreammagic import StreamMagicConnectionError, StreamMagicError
|
||||||
|
|
||||||
|
DOMAIN = "cambridge_audio"
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
|
STREAM_MAGIC_EXCEPTIONS = (
|
||||||
|
StreamMagicConnectionError,
|
||||||
|
StreamMagicError,
|
||||||
|
asyncio.CancelledError,
|
||||||
|
TimeoutError,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONNECT_TIMEOUT = 5
|
26
homeassistant/components/cambridge_audio/entity.py
Normal file
26
homeassistant/components/cambridge_audio/entity.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""Base class for Cambridge Audio entities."""
|
||||||
|
|
||||||
|
from aiostreammagic import StreamMagicClient
|
||||||
|
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class CambridgeAudioEntity(Entity):
|
||||||
|
"""Defines a base Cambridge Audio entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, client: StreamMagicClient) -> None:
|
||||||
|
"""Initialize Cambridge Audio entity."""
|
||||||
|
self.client = client
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, client.info.unit_id)},
|
||||||
|
name=client.info.name,
|
||||||
|
manufacturer="Cambridge Audio",
|
||||||
|
model=client.info.model,
|
||||||
|
serial_number=client.info.unit_id,
|
||||||
|
configuration_url=f"http://{client.host}",
|
||||||
|
)
|
12
homeassistant/components/cambridge_audio/manifest.json
Normal file
12
homeassistant/components/cambridge_audio/manifest.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"domain": "cambridge_audio",
|
||||||
|
"name": "Cambridge Audio",
|
||||||
|
"codeowners": ["@noahhusby"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/cambridge_audio",
|
||||||
|
"integration_type": "device",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"loggers": ["aiostreammagic"],
|
||||||
|
"requirements": ["aiostreammagic==2.0.3"],
|
||||||
|
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||||
|
}
|
190
homeassistant/components/cambridge_audio/media_player.py
Normal file
190
homeassistant/components/cambridge_audio/media_player.py
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"""Support for Cambridge Audio AV Receiver."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiostreammagic import StreamMagicClient
|
||||||
|
|
||||||
|
from homeassistant.components.media_player import (
|
||||||
|
MediaPlayerEntity,
|
||||||
|
MediaPlayerEntityFeature,
|
||||||
|
MediaPlayerState,
|
||||||
|
MediaType,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .entity import CambridgeAudioEntity
|
||||||
|
|
||||||
|
BASE_FEATURES = (
|
||||||
|
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
|
| MediaPlayerEntityFeature.TURN_OFF
|
||||||
|
| MediaPlayerEntityFeature.TURN_ON
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Cambridge Audio device based on a config entry."""
|
||||||
|
client: StreamMagicClient = entry.runtime_data
|
||||||
|
async_add_entities([CambridgeAudioDevice(client)])
|
||||||
|
|
||||||
|
|
||||||
|
class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
|
||||||
|
"""Representation of a Cambridge Audio Media Player Device."""
|
||||||
|
|
||||||
|
_attr_name = None
|
||||||
|
_attr_media_content_type = MediaType.MUSIC
|
||||||
|
|
||||||
|
def __init__(self, client: StreamMagicClient) -> None:
|
||||||
|
"""Initialize an Cambridge Audio entity."""
|
||||||
|
super().__init__(client)
|
||||||
|
self._attr_unique_id = client.info.unit_id
|
||||||
|
|
||||||
|
async def _state_update_callback(self, _client: StreamMagicClient) -> None:
|
||||||
|
"""Call when the device is notified of changes."""
|
||||||
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Register callback handlers."""
|
||||||
|
await self.client.register_state_update_callbacks(self._state_update_callback)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
"""Remove callbacks."""
|
||||||
|
await self.client.unregister_state_update_callbacks(self._state_update_callback)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||||
|
"""Supported features for the media player."""
|
||||||
|
controls = self.client.now_playing.controls
|
||||||
|
features = BASE_FEATURES
|
||||||
|
if "play_pause" in controls:
|
||||||
|
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
|
||||||
|
if "play" in controls:
|
||||||
|
features |= MediaPlayerEntityFeature.PLAY
|
||||||
|
if "pause" in controls:
|
||||||
|
features |= MediaPlayerEntityFeature.PAUSE
|
||||||
|
if "track_next" in controls:
|
||||||
|
features |= MediaPlayerEntityFeature.NEXT_TRACK
|
||||||
|
if "track_previous" in controls:
|
||||||
|
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||||
|
return features
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> MediaPlayerState:
|
||||||
|
"""Return the state of the device."""
|
||||||
|
media_state = self.client.play_state.state
|
||||||
|
if media_state == "NETWORK":
|
||||||
|
return MediaPlayerState.STANDBY
|
||||||
|
if self.client.state.power:
|
||||||
|
if media_state == "play":
|
||||||
|
return MediaPlayerState.PLAYING
|
||||||
|
if media_state == "pause":
|
||||||
|
return MediaPlayerState.PAUSED
|
||||||
|
if media_state == "connecting":
|
||||||
|
return MediaPlayerState.BUFFERING
|
||||||
|
if media_state in ("stop", "ready"):
|
||||||
|
return MediaPlayerState.IDLE
|
||||||
|
return MediaPlayerState.ON
|
||||||
|
return MediaPlayerState.OFF
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_list(self) -> list[str]:
|
||||||
|
"""Return a list of available input sources."""
|
||||||
|
return [item.name for item in self.client.sources]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str | None:
|
||||||
|
"""Return the current input source."""
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
item.name
|
||||||
|
for item in self.client.sources
|
||||||
|
if item.id == self.client.state.source
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_title(self) -> str | None:
|
||||||
|
"""Title of current playing media."""
|
||||||
|
return self.client.play_state.metadata.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_artist(self) -> str | None:
|
||||||
|
"""Artist of current playing media, music track only."""
|
||||||
|
return self.client.play_state.metadata.artist
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_album_name(self) -> str | None:
|
||||||
|
"""Album name of current playing media, music track only."""
|
||||||
|
return self.client.play_state.metadata.album
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_image_url(self) -> str | None:
|
||||||
|
"""Image url of current playing media."""
|
||||||
|
return self.client.play_state.metadata.art_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_duration(self) -> int | None:
|
||||||
|
"""Duration of the current media."""
|
||||||
|
return self.client.play_state.metadata.duration
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position(self) -> int | None:
|
||||||
|
"""Position of the current media."""
|
||||||
|
return self.client.play_state.position
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position_updated_at(self) -> datetime:
|
||||||
|
"""Last time the media position was updated."""
|
||||||
|
return self.client.position_last_updated
|
||||||
|
|
||||||
|
async def async_media_play_pause(self) -> None:
|
||||||
|
"""Toggle play/pause the current media."""
|
||||||
|
await self.client.play_pause()
|
||||||
|
|
||||||
|
async def async_media_pause(self) -> None:
|
||||||
|
"""Pause the current media."""
|
||||||
|
controls = self.client.now_playing.controls
|
||||||
|
if "pause" not in controls and "play_pause" in controls:
|
||||||
|
await self.client.play_pause()
|
||||||
|
else:
|
||||||
|
await self.client.pause()
|
||||||
|
|
||||||
|
async def async_media_stop(self) -> None:
|
||||||
|
"""Stop the current media."""
|
||||||
|
await self.client.stop()
|
||||||
|
|
||||||
|
async def async_media_play(self) -> None:
|
||||||
|
"""Play the current media."""
|
||||||
|
if self.state == MediaPlayerState.PAUSED:
|
||||||
|
await self.client.play_pause()
|
||||||
|
|
||||||
|
async def async_media_next_track(self) -> None:
|
||||||
|
"""Skip to the next track."""
|
||||||
|
await self.client.next_track()
|
||||||
|
|
||||||
|
async def async_media_previous_track(self) -> None:
|
||||||
|
"""Skip to the previous track."""
|
||||||
|
await self.client.previous_track()
|
||||||
|
|
||||||
|
async def async_select_source(self, source: str) -> None:
|
||||||
|
"""Select the source."""
|
||||||
|
for src in self.client.sources:
|
||||||
|
if src.name == source:
|
||||||
|
await self.client.set_source_by_id(src.id)
|
||||||
|
break
|
||||||
|
|
||||||
|
async def async_turn_on(self) -> None:
|
||||||
|
"""Power on the device."""
|
||||||
|
await self.client.power_on()
|
||||||
|
|
||||||
|
async def async_turn_off(self) -> None:
|
||||||
|
"""Power off the device."""
|
||||||
|
await self.client.power_off()
|
26
homeassistant/components/cambridge_audio/strings.json
Normal file
26
homeassistant/components/cambridge_audio/strings.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"flow_title": "{name}",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Set up your Cambridge Audio Streamer to integrate with Home Assistant.",
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The hostname or IP address of the Cambridge Audio Streamer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "Do you want to setup {name}?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -100,6 +100,7 @@ FLOWS = {
|
|||||||
"bthome",
|
"bthome",
|
||||||
"buienradar",
|
"buienradar",
|
||||||
"caldav",
|
"caldav",
|
||||||
|
"cambridge_audio",
|
||||||
"canary",
|
"canary",
|
||||||
"cast",
|
"cast",
|
||||||
"ccm15",
|
"ccm15",
|
||||||
|
@ -849,6 +849,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "cloud_polling"
|
||||||
},
|
},
|
||||||
|
"cambridge_audio": {
|
||||||
|
"name": "Cambridge Audio",
|
||||||
|
"integration_type": "device",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
},
|
||||||
"canary": {
|
"canary": {
|
||||||
"name": "Canary",
|
"name": "Canary",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
@ -764,6 +764,11 @@ ZEROCONF = {
|
|||||||
"name": "slzb-06*",
|
"name": "slzb-06*",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"_smoip._tcp.local.": [
|
||||||
|
{
|
||||||
|
"domain": "cambridge_audio",
|
||||||
|
},
|
||||||
|
],
|
||||||
"_sonos._tcp.local.": [
|
"_sonos._tcp.local.": [
|
||||||
{
|
{
|
||||||
"domain": "sonos",
|
"domain": "sonos",
|
||||||
@ -793,6 +798,11 @@ ZEROCONF = {
|
|||||||
"name": "smappee50*",
|
"name": "smappee50*",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"_stream-magic._tcp.local.": [
|
||||||
|
{
|
||||||
|
"domain": "cambridge_audio",
|
||||||
|
},
|
||||||
|
],
|
||||||
"_system-bridge._tcp.local.": [
|
"_system-bridge._tcp.local.": [
|
||||||
{
|
{
|
||||||
"domain": "system_bridge",
|
"domain": "system_bridge",
|
||||||
|
@ -373,6 +373,9 @@ aiosolaredge==0.2.0
|
|||||||
# homeassistant.components.steamist
|
# homeassistant.components.steamist
|
||||||
aiosteamist==1.0.0
|
aiosteamist==1.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.cambridge_audio
|
||||||
|
aiostreammagic==2.0.3
|
||||||
|
|
||||||
# homeassistant.components.switcher_kis
|
# homeassistant.components.switcher_kis
|
||||||
aioswitcher==4.0.3
|
aioswitcher==4.0.3
|
||||||
|
|
||||||
|
@ -355,6 +355,9 @@ aiosolaredge==0.2.0
|
|||||||
# homeassistant.components.steamist
|
# homeassistant.components.steamist
|
||||||
aiosteamist==1.0.0
|
aiosteamist==1.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.cambridge_audio
|
||||||
|
aiostreammagic==2.0.3
|
||||||
|
|
||||||
# homeassistant.components.switcher_kis
|
# homeassistant.components.switcher_kis
|
||||||
aioswitcher==4.0.3
|
aioswitcher==4.0.3
|
||||||
|
|
||||||
|
13
tests/components/cambridge_audio/__init__.py
Normal file
13
tests/components/cambridge_audio/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Tests for the Cambridge Audio integration."""
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||||
|
"""Fixture for setting up the component."""
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
55
tests/components/cambridge_audio/conftest.py
Normal file
55
tests/components/cambridge_audio/conftest.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"""Cambridge Audio tests configuration."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from aiostreammagic.models import Info
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.cambridge_audio.const import DOMAIN
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, load_fixture
|
||||||
|
from tests.components.smhi.common import AsyncMock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||||
|
"""Override async_setup_entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.cambridge_audio.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
yield mock_setup_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_stream_magic_client() -> Generator[AsyncMock]:
|
||||||
|
"""Mock an Cambridge Audio client."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.cambridge_audio.StreamMagicClient",
|
||||||
|
autospec=True,
|
||||||
|
) as mock_client,
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.cambridge_audio.config_flow.StreamMagicClient",
|
||||||
|
new=mock_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
client = mock_client.return_value
|
||||||
|
client.host = "192.168.20.218"
|
||||||
|
client.info = Info.from_json(load_fixture("get_info.json", DOMAIN))
|
||||||
|
client.is_connected = Mock(return_value=True)
|
||||||
|
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry() -> MockConfigEntry:
|
||||||
|
"""Mock a config entry."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Cambridge Audio CXNv2",
|
||||||
|
data={CONF_HOST: "192.168.20.218"},
|
||||||
|
unique_id="0020c2d8",
|
||||||
|
)
|
32
tests/components/cambridge_audio/fixtures/get_info.json
Normal file
32
tests/components/cambridge_audio/fixtures/get_info.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "Cambridge Audio CXNv2",
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"locale": "en_GB",
|
||||||
|
"usage_reports": true,
|
||||||
|
"setup": true,
|
||||||
|
"sources_setup": true,
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"component": "cast",
|
||||||
|
"version": "1.52.272222"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "MCU",
|
||||||
|
"version": "3.1+0.5+36"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "service-pack",
|
||||||
|
"version": "v022-a-151+a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "application",
|
||||||
|
"version": "1.0+gitAUTOINC+a94a3e2ad8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"udn": "02680b5c-1320-4d54-9f7c-3cfe915ad4c3",
|
||||||
|
"hcv": 3764,
|
||||||
|
"model": "CXNv2",
|
||||||
|
"unit_id": "0020c2d8",
|
||||||
|
"max_http_body_size": 65536,
|
||||||
|
"api": "1.8"
|
||||||
|
}
|
33
tests/components/cambridge_audio/snapshots/test_init.ambr
Normal file
33
tests/components/cambridge_audio/snapshots/test_init.ambr
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_device_info
|
||||||
|
DeviceRegistryEntrySnapshot({
|
||||||
|
'area_id': None,
|
||||||
|
'config_entries': <ANY>,
|
||||||
|
'configuration_url': 'http://192.168.20.218',
|
||||||
|
'connections': set({
|
||||||
|
}),
|
||||||
|
'disabled_by': None,
|
||||||
|
'entry_type': None,
|
||||||
|
'hw_version': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'identifiers': set({
|
||||||
|
tuple(
|
||||||
|
'cambridge_audio',
|
||||||
|
'0020c2d8',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
'is_new': False,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'manufacturer': 'Cambridge Audio',
|
||||||
|
'model': 'CXNv2',
|
||||||
|
'model_id': None,
|
||||||
|
'name': 'Cambridge Audio CXNv2',
|
||||||
|
'name_by_user': None,
|
||||||
|
'primary_config_entry': <ANY>,
|
||||||
|
'serial_number': '0020c2d8',
|
||||||
|
'suggested_area': None,
|
||||||
|
'sw_version': None,
|
||||||
|
'via_device_id': None,
|
||||||
|
})
|
||||||
|
# ---
|
194
tests/components/cambridge_audio/test_config_flow.py
Normal file
194
tests/components/cambridge_audio/test_config_flow.py
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
"""Tests for the Cambridge Audio config flow."""
|
||||||
|
|
||||||
|
from ipaddress import ip_address
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from aiostreammagic import StreamMagicError
|
||||||
|
|
||||||
|
from homeassistant.components.cambridge_audio.const import DOMAIN
|
||||||
|
from homeassistant.components.zeroconf import ZeroconfServiceInfo
|
||||||
|
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
|
||||||
|
ip_address=ip_address("192.168.20.218"),
|
||||||
|
ip_addresses=[ip_address("192.168.20.218")],
|
||||||
|
hostname="cambridge_CXNv2.local.",
|
||||||
|
name="cambridge_CXNv2._stream-magic._tcp.local.",
|
||||||
|
port=80,
|
||||||
|
type="_stream-magic._tcp.local.",
|
||||||
|
properties={
|
||||||
|
"serial": "0020c2d8",
|
||||||
|
"hcv": "3764",
|
||||||
|
"software": "v022-a-151+a",
|
||||||
|
"model": "CXNv2",
|
||||||
|
"udn": "02680b5c-1320-4d54-9f7c-3cfe915ad4c3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_stream_magic_client: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test full flow."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_USER},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: "192.168.20.218"},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Cambridge Audio CXNv2"
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_HOST: "192.168.20.218",
|
||||||
|
}
|
||||||
|
assert result["result"].unique_id == "0020c2d8"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_stream_magic_client: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test flow errors."""
|
||||||
|
mock_stream_magic_client.connect.side_effect = StreamMagicError
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_USER},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: "192.168.20.218"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
mock_stream_magic_client.connect.side_effect = None
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: "192.168.20.218"},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_stream_magic_client: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test duplicate flow."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_USER},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: "192.168.20.218"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zeroconf_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_stream_magic_client: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test zeroconf flow."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_ZEROCONF},
|
||||||
|
data=ZEROCONF_DISCOVERY,
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "discovery_confirm"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Cambridge Audio CXNv2"
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_HOST: "192.168.20.218",
|
||||||
|
}
|
||||||
|
assert result["result"].unique_id == "0020c2d8"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zeroconf_flow_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_stream_magic_client: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test zeroconf flow."""
|
||||||
|
mock_stream_magic_client.connect.side_effect = StreamMagicError
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_ZEROCONF},
|
||||||
|
data=ZEROCONF_DISCOVERY,
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "cannot_connect"
|
||||||
|
|
||||||
|
mock_stream_magic_client.connect.side_effect = None
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_ZEROCONF},
|
||||||
|
data=ZEROCONF_DISCOVERY,
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "discovery_confirm"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Cambridge Audio CXNv2"
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_HOST: "192.168.20.218",
|
||||||
|
}
|
||||||
|
assert result["result"].unique_id == "0020c2d8"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zeroconf_duplicate(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_stream_magic_client: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test duplicate flow."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_ZEROCONF},
|
||||||
|
data=ZEROCONF_DISCOVERY,
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
29
tests/components/cambridge_audio/test_init.py
Normal file
29
tests/components/cambridge_audio/test_init.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Tests for the Cambridge Audio integration."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from syrupy import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components.cambridge_audio.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
|
from . import setup_integration
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_device_info(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
mock_stream_magic_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test device registry integration."""
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
device_entry = device_registry.async_get_device(
|
||||||
|
identifiers={(DOMAIN, mock_config_entry.unique_id)}
|
||||||
|
)
|
||||||
|
assert device_entry is not None
|
||||||
|
assert device_entry == snapshot
|
Loading…
x
Reference in New Issue
Block a user