From 650c92a3cfb5a6f89c4bb63bcf42ea9497c25ef5 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:27:51 -0400 Subject: [PATCH] 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 --- CODEOWNERS | 2 + .../components/cambridge_audio/__init__.py | 46 +++++ .../components/cambridge_audio/config_flow.py | 93 +++++++++ .../components/cambridge_audio/const.py | 19 ++ .../components/cambridge_audio/entity.py | 26 +++ .../components/cambridge_audio/manifest.json | 12 ++ .../cambridge_audio/media_player.py | 190 +++++++++++++++++ .../components/cambridge_audio/strings.json | 26 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/cambridge_audio/__init__.py | 13 ++ tests/components/cambridge_audio/conftest.py | 55 +++++ .../cambridge_audio/fixtures/get_info.json | 32 +++ .../cambridge_audio/snapshots/test_init.ambr | 33 +++ .../cambridge_audio/test_config_flow.py | 194 ++++++++++++++++++ tests/components/cambridge_audio/test_init.py | 29 +++ 19 files changed, 793 insertions(+) create mode 100644 homeassistant/components/cambridge_audio/__init__.py create mode 100644 homeassistant/components/cambridge_audio/config_flow.py create mode 100644 homeassistant/components/cambridge_audio/const.py create mode 100644 homeassistant/components/cambridge_audio/entity.py create mode 100644 homeassistant/components/cambridge_audio/manifest.json create mode 100644 homeassistant/components/cambridge_audio/media_player.py create mode 100644 homeassistant/components/cambridge_audio/strings.json create mode 100644 tests/components/cambridge_audio/__init__.py create mode 100644 tests/components/cambridge_audio/conftest.py create mode 100644 tests/components/cambridge_audio/fixtures/get_info.json create mode 100644 tests/components/cambridge_audio/snapshots/test_init.ambr create mode 100644 tests/components/cambridge_audio/test_config_flow.py create mode 100644 tests/components/cambridge_audio/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index bd4494b8249..42a0ab8e55d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -238,6 +238,8 @@ build.json @home-assistant/supervisor /tests/components/button/ @home-assistant/core /homeassistant/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 /tests/components/camera/ @home-assistant/core /homeassistant/components/cast/ @emontnemery diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py new file mode 100644 index 00000000000..344045fe550 --- /dev/null +++ b/homeassistant/components/cambridge_audio/__init__.py @@ -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 diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py new file mode 100644 index 00000000000..201e531608d --- /dev/null +++ b/homeassistant/components/cambridge_audio/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/cambridge_audio/const.py b/homeassistant/components/cambridge_audio/const.py new file mode 100644 index 00000000000..5a4e5a1f2e0 --- /dev/null +++ b/homeassistant/components/cambridge_audio/const.py @@ -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 diff --git a/homeassistant/components/cambridge_audio/entity.py b/homeassistant/components/cambridge_audio/entity.py new file mode 100644 index 00000000000..5ea9c7ab685 --- /dev/null +++ b/homeassistant/components/cambridge_audio/entity.py @@ -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}", + ) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json new file mode 100644 index 00000000000..71c5368b631 --- /dev/null +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -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."] +} diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py new file mode 100644 index 00000000000..a60c5420cd8 --- /dev/null +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -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() diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json new file mode 100644 index 00000000000..fa27dc452de --- /dev/null +++ b/homeassistant/components/cambridge_audio/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2e38d608bd9..2d9d8861155 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -100,6 +100,7 @@ FLOWS = { "bthome", "buienradar", "caldav", + "cambridge_audio", "canary", "cast", "ccm15", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cd37adc3f71..ae77dfdd04e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -849,6 +849,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "cambridge_audio": { + "name": "Cambridge Audio", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "canary": { "name": "Canary", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2e3ffa23ff5..f627f1f0f47 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -764,6 +764,11 @@ ZEROCONF = { "name": "slzb-06*", }, ], + "_smoip._tcp.local.": [ + { + "domain": "cambridge_audio", + }, + ], "_sonos._tcp.local.": [ { "domain": "sonos", @@ -793,6 +798,11 @@ ZEROCONF = { "name": "smappee50*", }, ], + "_stream-magic._tcp.local.": [ + { + "domain": "cambridge_audio", + }, + ], "_system-bridge._tcp.local.": [ { "domain": "system_bridge", diff --git a/requirements_all.txt b/requirements_all.txt index f5b4cc47acf..86e7e087678 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -373,6 +373,9 @@ aiosolaredge==0.2.0 # homeassistant.components.steamist aiosteamist==1.0.0 +# homeassistant.components.cambridge_audio +aiostreammagic==2.0.3 + # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63e6b6ce4a2..f58cac3f00a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,6 +355,9 @@ aiosolaredge==0.2.0 # homeassistant.components.steamist aiosteamist==1.0.0 +# homeassistant.components.cambridge_audio +aiostreammagic==2.0.3 + # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/tests/components/cambridge_audio/__init__.py b/tests/components/cambridge_audio/__init__.py new file mode 100644 index 00000000000..f6b5f48d39d --- /dev/null +++ b/tests/components/cambridge_audio/__init__.py @@ -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() diff --git a/tests/components/cambridge_audio/conftest.py b/tests/components/cambridge_audio/conftest.py new file mode 100644 index 00000000000..931c0f30af1 --- /dev/null +++ b/tests/components/cambridge_audio/conftest.py @@ -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", + ) diff --git a/tests/components/cambridge_audio/fixtures/get_info.json b/tests/components/cambridge_audio/fixtures/get_info.json new file mode 100644 index 00000000000..ee88995412e --- /dev/null +++ b/tests/components/cambridge_audio/fixtures/get_info.json @@ -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" +} diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr new file mode 100644 index 00000000000..64182ee2188 --- /dev/null +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.20.218', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': '0020c2d8', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- \ No newline at end of file diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py new file mode 100644 index 00000000000..9a2d077b8f8 --- /dev/null +++ b/tests/components/cambridge_audio/test_config_flow.py @@ -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" diff --git a/tests/components/cambridge_audio/test_init.py b/tests/components/cambridge_audio/test_init.py new file mode 100644 index 00000000000..7dea193d9fd --- /dev/null +++ b/tests/components/cambridge_audio/test_init.py @@ -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