From d3b04a5a581a4acf0f009c4f9664ade85428350a Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:56:17 +0100 Subject: [PATCH] Add Devialet integration (#86551) * Add Devialet * Bump Devialet==1.4.0 * Bump Devialet==1.4.1 * Sort manifest and add shorthand * Black formatting * Fix incompatible type * Add type guarding for name * Rename host keywork in tests * Fix Devialet tests * Add update coordinator * Update devialet tests * Create unique_id from entry data --- CODEOWNERS | 2 + homeassistant/components/devialet/__init__.py | 31 ++ .../components/devialet/config_flow.py | 104 ++++++ homeassistant/components/devialet/const.py | 12 + .../components/devialet/coordinator.py | 32 ++ .../components/devialet/diagnostics.py | 20 ++ .../components/devialet/manifest.json | 12 + .../components/devialet/media_player.py | 210 ++++++++++++ .../components/devialet/strings.json | 22 ++ .../components/devialet/translations/en.json | 22 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/devialet/__init__.py | 150 +++++++++ .../devialet/fixtures/current_position.json | 3 + .../devialet/fixtures/equalizer.json | 26 ++ .../devialet/fixtures/general_info.json | 18 + .../devialet/fixtures/night_mode.json | 3 + .../devialet/fixtures/no_current_source.json | 7 + .../devialet/fixtures/source_state.json | 20 ++ .../components/devialet/fixtures/sources.json | 41 +++ .../devialet/fixtures/system_info.json | 6 + .../components/devialet/fixtures/volume.json | 3 + tests/components/devialet/test_config_flow.py | 154 +++++++++ tests/components/devialet/test_diagnostics.py | 40 +++ tests/components/devialet/test_init.py | 49 +++ .../components/devialet/test_media_player.py | 312 ++++++++++++++++++ 29 files changed, 1317 insertions(+) create mode 100644 homeassistant/components/devialet/__init__.py create mode 100644 homeassistant/components/devialet/config_flow.py create mode 100644 homeassistant/components/devialet/const.py create mode 100644 homeassistant/components/devialet/coordinator.py create mode 100644 homeassistant/components/devialet/diagnostics.py create mode 100644 homeassistant/components/devialet/manifest.json create mode 100644 homeassistant/components/devialet/media_player.py create mode 100644 homeassistant/components/devialet/strings.json create mode 100644 homeassistant/components/devialet/translations/en.json create mode 100644 tests/components/devialet/__init__.py create mode 100644 tests/components/devialet/fixtures/current_position.json create mode 100644 tests/components/devialet/fixtures/equalizer.json create mode 100644 tests/components/devialet/fixtures/general_info.json create mode 100644 tests/components/devialet/fixtures/night_mode.json create mode 100644 tests/components/devialet/fixtures/no_current_source.json create mode 100644 tests/components/devialet/fixtures/source_state.json create mode 100644 tests/components/devialet/fixtures/sources.json create mode 100644 tests/components/devialet/fixtures/system_info.json create mode 100644 tests/components/devialet/fixtures/volume.json create mode 100644 tests/components/devialet/test_config_flow.py create mode 100644 tests/components/devialet/test_diagnostics.py create mode 100644 tests/components/devialet/test_init.py create mode 100644 tests/components/devialet/test_media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 60071eeeb61..ec32f941d56 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -259,6 +259,8 @@ build.json @home-assistant/supervisor /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney /tests/components/derivative/ @afaucogney +/homeassistant/components/devialet/ @fwestenberg +/tests/components/devialet/ @fwestenberg /homeassistant/components/device_automation/ @home-assistant/core /tests/components/device_automation/ @home-assistant/core /homeassistant/components/device_tracker/ @home-assistant/core diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py new file mode 100644 index 00000000000..034f93abb68 --- /dev/null +++ b/homeassistant/components/devialet/__init__.py @@ -0,0 +1,31 @@ +"""The Devialet integration.""" +from __future__ import annotations + +from devialet import DevialetApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Devialet from a config entry.""" + session = async_get_clientsession(hass) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DevialetApi( + entry.data[CONF_HOST], session + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Devialet config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py new file mode 100644 index 00000000000..de52788de50 --- /dev/null +++ b/homeassistant/components/devialet/config_flow.py @@ -0,0 +1,104 @@ +"""Support for Devialet Phantom speakers.""" +from __future__ import annotations + +import logging +from typing import Any + +from devialet.devialet_api import DevialetApi +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +LOGGER = logging.getLogger(__package__) + + +class DevialetFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Devialet.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self._host: str | None = None + self._name: str | None = None + self._model: str | None = None + self._serial: str | None = None + self._errors: dict[str, str] = {} + + async def async_validate_input(self) -> FlowResult | None: + """Validate the input using the Devialet API.""" + + self._errors.clear() + session = async_get_clientsession(self.hass) + client = DevialetApi(self._host, session) + + if not await client.async_update() or client.serial is None: + self._errors["base"] = "cannot_connect" + LOGGER.error("Cannot connect") + return None + + await self.async_set_unique_id(client.serial) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=client.device_name, + data={CONF_HOST: self._host, CONF_NAME: client.device_name}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user or zeroconf.""" + + if user_input is not None: + self._host = user_input[CONF_HOST] + result = await self.async_validate_input() + if result is not None: + return result + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=self._errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle a flow initialized by zeroconf discovery.""" + LOGGER.info("Devialet device found via ZEROCONF: %s", discovery_info) + + self._host = discovery_info.host + self._name = discovery_info.name.split(".", 1)[0] + self._model = discovery_info.properties["model"] + self._serial = discovery_info.properties["serialNumber"] + + await self.async_set_unique_id(self._serial) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = {"title": self._name} + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-confirmation of discovered node.""" + title = f"{self._name} ({self._model})" + + if user_input is not None: + result = await self.async_validate_input() + if result is not None: + return result + + return self.async_show_form( + step_id="confirm", + description_placeholders={"device": self._model, "title": title}, + errors=self._errors, + last_step=True, + ) diff --git a/homeassistant/components/devialet/const.py b/homeassistant/components/devialet/const.py new file mode 100644 index 00000000000..ccb4fbc7964 --- /dev/null +++ b/homeassistant/components/devialet/const.py @@ -0,0 +1,12 @@ +"""Constants for the Devialet integration.""" +from typing import Final + +DOMAIN: Final = "devialet" +MANUFACTURER: Final = "Devialet" + +SOUND_MODES = { + "Custom": "custom", + "Flat": "flat", + "Night mode": "night mode", + "Voice": "voice", +} diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py new file mode 100644 index 00000000000..f0ee47150cc --- /dev/null +++ b/homeassistant/components/devialet/coordinator.py @@ -0,0 +1,32 @@ +"""Class representing a Devialet update coordinator.""" +from datetime import timedelta +import logging + +from devialet import DevialetApi + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + + +class DevialetCoordinator(DataUpdateCoordinator): + """Devialet update coordinator.""" + + def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + await self.client.async_update() diff --git a/homeassistant/components/devialet/diagnostics.py b/homeassistant/components/devialet/diagnostics.py new file mode 100644 index 00000000000..f9824a9cad1 --- /dev/null +++ b/homeassistant/components/devialet/diagnostics.py @@ -0,0 +1,20 @@ +"""Diagnostics support for Devialet.""" +from __future__ import annotations + +from typing import Any + +from devialet import DevialetApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client: DevialetApi = hass.data[DOMAIN][entry.entry_id] + + return await client.async_get_diagnostics() diff --git a/homeassistant/components/devialet/manifest.json b/homeassistant/components/devialet/manifest.json new file mode 100644 index 00000000000..286b9bfb112 --- /dev/null +++ b/homeassistant/components/devialet/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "devialet", + "name": "Devialet", + "after_dependencies": ["zeroconf"], + "codeowners": ["@fwestenberg"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/devialet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["devialet==1.4.3"], + "zeroconf": ["_devialet-http._tcp.local."] +} diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py new file mode 100644 index 00000000000..75fc420fa87 --- /dev/null +++ b/homeassistant/components/devialet/media_player.py @@ -0,0 +1,210 @@ +"""Support for Devialet speakers.""" +from __future__ import annotations + +from devialet.const import NORMAL_INPUTS + +from homeassistant.components.media_player import ( + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, SOUND_MODES +from .coordinator import DevialetCoordinator + +SUPPORT_DEVIALET = ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE +) + +DEVIALET_TO_HA_FEATURE_MAP = { + "play": MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP, + "pause": MediaPlayerEntityFeature.PAUSE, + "previous": MediaPlayerEntityFeature.PREVIOUS_TRACK, + "next": MediaPlayerEntityFeature.NEXT_TRACK, + "seek": MediaPlayerEntityFeature.SEEK, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Devialet entry.""" + client = hass.data[DOMAIN][entry.entry_id] + coordinator = DevialetCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + + async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) + + +class DevialetMediaPlayerEntity(CoordinatorEntity, MediaPlayerEntity): + """Devialet media player.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, coordinator, entry: ConfigEntry) -> None: + """Initialize the Devialet device.""" + self.coordinator = coordinator + super().__init__(coordinator) + + self._attr_unique_id = str(entry.unique_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + model=self.coordinator.client.model, + name=entry.data[CONF_NAME], + sw_version=self.coordinator.client.version, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.client.is_available: + self.async_write_ha_state() + return + + self._attr_volume_level = self.coordinator.client.volume_level + self._attr_is_volume_muted = self.coordinator.client.is_volume_muted + self._attr_source_list = self.coordinator.client.source_list + self._attr_sound_mode_list = sorted(SOUND_MODES) + self._attr_media_artist = self.coordinator.client.media_artist + self._attr_media_album_name = self.coordinator.client.media_album_name + self._attr_media_artist = self.coordinator.client.media_artist + self._attr_media_image_url = self.coordinator.client.media_image_url + self._attr_media_duration = self.coordinator.client.media_duration + self._attr_media_position = self.coordinator.client.current_position + self._attr_media_position_updated_at = ( + self.coordinator.client.position_updated_at + ) + self._attr_media_title = ( + self.coordinator.client.media_title + if self.coordinator.client.media_title + else self.source + ) + self.async_write_ha_state() + + @property + def state(self) -> MediaPlayerState | None: + """Return the state of the device.""" + playing_state = self.coordinator.client.playing_state + + if not playing_state: + return MediaPlayerState.IDLE + if playing_state == "playing": + return MediaPlayerState.PLAYING + if playing_state == "paused": + return MediaPlayerState.PAUSED + return MediaPlayerState.ON + + @property + def available(self) -> bool: + """Return if the media player is available.""" + return self.coordinator.client.is_available + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Flag media player features that are supported.""" + features = SUPPORT_DEVIALET + + if self.coordinator.client.source_state is None: + return features + + if not self.coordinator.client.available_options: + return features + + for option in self.coordinator.client.available_options: + features |= DEVIALET_TO_HA_FEATURE_MAP.get(option, 0) + return features + + @property + def source(self) -> str | None: + """Return the current input source.""" + source = self.coordinator.client.source + + for pretty_name, name in NORMAL_INPUTS.items(): + if source == name: + return pretty_name + return None + + @property + def sound_mode(self) -> str | None: + """Return the current sound mode.""" + if self.coordinator.client.equalizer is not None: + sound_mode = self.coordinator.client.equalizer + elif self.coordinator.client.night_mode: + sound_mode = "night mode" + else: + return None + + for pretty_name, mode in SOUND_MODES.items(): + if sound_mode == mode: + return pretty_name + return None + + async def async_volume_up(self) -> None: + """Volume up media player.""" + await self.coordinator.client.async_volume_up() + + async def async_volume_down(self) -> None: + """Volume down media player.""" + await self.coordinator.client.async_volume_down() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self.coordinator.client.async_set_volume_level(volume) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute (true) or unmute (false) media player.""" + await self.coordinator.client.async_mute_volume(mute) + + async def async_media_play(self) -> None: + """Play media player.""" + await self.coordinator.client.async_media_play() + + async def async_media_pause(self) -> None: + """Pause media player.""" + await self.coordinator.client.async_media_pause() + + async def async_media_stop(self) -> None: + """Pause media player.""" + await self.coordinator.client.async_media_stop() + + async def async_media_next_track(self) -> None: + """Send the next track command.""" + await self.coordinator.client.async_media_next_track() + + async def async_media_previous_track(self) -> None: + """Send the previous track command.""" + await self.coordinator.client.async_media_previous_track() + + async def async_media_seek(self, position: float) -> None: + """Send seek command.""" + await self.coordinator.client.async_media_seek(position) + + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Send sound mode command.""" + for pretty_name, mode in SOUND_MODES.items(): + if sound_mode == pretty_name: + if mode == "night mode": + await self.coordinator.client.async_set_night_mode(True) + else: + await self.coordinator.client.async_set_night_mode(False) + await self.coordinator.client.async_set_equalizer(mode) + + async def async_turn_off(self) -> None: + """Turn off media player.""" + await self.coordinator.client.async_turn_off() + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + await self.coordinator.client.async_select_source(source) diff --git a/homeassistant/components/devialet/strings.json b/homeassistant/components/devialet/strings.json new file mode 100644 index 00000000000..0a90da49bf4 --- /dev/null +++ b/homeassistant/components/devialet/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "{title}", + "step": { + "user": { + "description": "Please enter the host name or IP address of the Devialet device.", + "data": { + "host": "Host" + } + }, + "confirm": { + "description": "Do you want to set up Devialet device {device}?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/devialet/translations/en.json b/homeassistant/components/devialet/translations/en.json new file mode 100644 index 00000000000..af0cfc4c122 --- /dev/null +++ b/homeassistant/components/devialet/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{title}", + "step": { + "confirm": { + "description": "Do you want to set up Devialet device {device}?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Please enter the host name or IP address of the Devialet device." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fbd0b40551b..57503f0ef32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -95,6 +95,7 @@ FLOWS = { "deconz", "deluge", "denonavr", + "devialet", "devolo_home_control", "devolo_home_network", "dexcom", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 312a2838051..f0af72624f6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1067,6 +1067,12 @@ } } }, + "devialet": { + "name": "Devialet", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "device_sun_light_trigger": { "name": "Presence-based Lights", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 06daf8bc4a8..e8d117d1f33 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -364,6 +364,11 @@ ZEROCONF = { "domain": "forked_daapd", }, ], + "_devialet-http._tcp.local.": [ + { + "domain": "devialet", + }, + ], "_dkapi._tcp.local.": [ { "domain": "daikin", diff --git a/requirements_all.txt b/requirements_all.txt index d2505e5726a..cc20abb8af8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -683,6 +683,9 @@ demetriek==0.4.0 # homeassistant.components.denonavr denonavr==0.11.4 +# homeassistant.components.devialet +devialet==1.4.3 + # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2098110ca7b..2a7a8c22e3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -558,6 +558,9 @@ demetriek==0.4.0 # homeassistant.components.denonavr denonavr==0.11.4 +# homeassistant.components.devialet +devialet==1.4.3 + # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/tests/components/devialet/__init__.py b/tests/components/devialet/__init__.py new file mode 100644 index 00000000000..28ab6229c44 --- /dev/null +++ b/tests/components/devialet/__init__.py @@ -0,0 +1,150 @@ +"""Tests for the Devialet integration.""" + +from ipaddress import ip_address + +from aiohttp import ClientError as ServerTimeoutError +from devialet.const import UrlSuffix + +from homeassistant.components import zeroconf +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +NAME = "Livingroom" +SERIAL = "L00P00000AB11" +HOST = "127.0.0.1" +CONF_INPUT = {CONF_HOST: HOST} + +CONF_DATA = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + +MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} +MOCK_USER_INPUT = {CONF_HOST: HOST} +MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(HOST), + ip_addresses=[ip_address(HOST)], + hostname="PhantomISilver-L00P00000AB11.local.", + type="_devialet-http._tcp.", + name="Livingroom", + port=80, + properties={ + "_raw": { + "firmwareFamily": "DOS", + "firmwareVersion": "2.16.1.49152", + "ipControlVersion": "1", + "manufacturer": "Devialet", + "model": "Phantom I Silver", + "path": "/ipcontrol/v1", + "serialNumber": "L00P00000AB11", + }, + "firmwareFamily": "DOS", + "firmwareVersion": "2.16.1.49152", + "ipControlVersion": "1", + "manufacturer": "Devialet", + "model": "Phantom I Silver", + "path": "/ipcontrol/v1", + "serialNumber": "L00P00000AB11", + }, +) + + +def mock_unavailable(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=ServerTimeoutError + ) + + +def mock_idle(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", + text=load_fixture("general_info.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_SOURCE}", + exc=ServerTimeoutError, + ) + + +def mock_playing(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", + text=load_fixture("general_info.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_SOURCE}", + text=load_fixture("source_state.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_SOURCES}", + text=load_fixture("sources.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_VOLUME}", + text=load_fixture("volume.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_NIGHT_MODE}", + text=load_fixture("night_mode.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_EQUALIZER}", + text=load_fixture("equalizer.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_POSITION}", + text=load_fixture("current_position.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +async def setup_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + skip_entry_setup: bool = False, + state: str = "playing", + serial: str = SERIAL, +) -> MockConfigEntry: + """Set up the Devialet integration in Home Assistant.""" + + if state == "playing": + mock_playing(aioclient_mock) + elif state == "unavailable": + mock_unavailable(aioclient_mock) + elif state == "idle": + mock_idle(aioclient_mock) + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial, + data=CONF_DATA, + ) + + entry.add_to_hass(hass) + + if not skip_entry_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/devialet/fixtures/current_position.json b/tests/components/devialet/fixtures/current_position.json new file mode 100644 index 00000000000..2b9761cc03a --- /dev/null +++ b/tests/components/devialet/fixtures/current_position.json @@ -0,0 +1,3 @@ +{ + "position": 123102 +} diff --git a/tests/components/devialet/fixtures/equalizer.json b/tests/components/devialet/fixtures/equalizer.json new file mode 100644 index 00000000000..be9ea651d6e --- /dev/null +++ b/tests/components/devialet/fixtures/equalizer.json @@ -0,0 +1,26 @@ +{ + "availablePresets": ["custom", "flat", "voice"], + "currentEqualization": { + "high": { + "gain": 0 + }, + "low": { + "gain": 0 + } + }, + "customEqualization": { + "high": { + "gain": 0 + }, + "low": { + "gain": 0 + } + }, + "enabled": true, + "gainRange": { + "max": 6, + "min": -6, + "stepPrecision": 1 + }, + "preset": "flat" +} diff --git a/tests/components/devialet/fixtures/general_info.json b/tests/components/devialet/fixtures/general_info.json new file mode 100644 index 00000000000..6ff1a724f08 --- /dev/null +++ b/tests/components/devialet/fixtures/general_info.json @@ -0,0 +1,18 @@ +{ + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "deviceName": "Livingroom", + "firmwareFamily": "DOS", + "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "ipControlVersion": "1", + "model": "Phantom I Silver", + "release": { + "buildType": "release", + "canonicalVersion": "2.16.1.49152", + "version": "2.16.1" + }, + "role": "FrontLeft", + "serial": "L00P00000AB11", + "standbyEntryDelay": 0, + "standbyState": "Unknown", + "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl" +} diff --git a/tests/components/devialet/fixtures/night_mode.json b/tests/components/devialet/fixtures/night_mode.json new file mode 100644 index 00000000000..e61cc12151d --- /dev/null +++ b/tests/components/devialet/fixtures/night_mode.json @@ -0,0 +1,3 @@ +{ + "nightMode": "off" +} diff --git a/tests/components/devialet/fixtures/no_current_source.json b/tests/components/devialet/fixtures/no_current_source.json new file mode 100644 index 00000000000..ac16468597d --- /dev/null +++ b/tests/components/devialet/fixtures/no_current_source.json @@ -0,0 +1,7 @@ +{ + "error": { + "code": "NoCurrentSource", + "details": {}, + "message": "" + } +} diff --git a/tests/components/devialet/fixtures/source_state.json b/tests/components/devialet/fixtures/source_state.json new file mode 100644 index 00000000000..d389675ac98 --- /dev/null +++ b/tests/components/devialet/fixtures/source_state.json @@ -0,0 +1,20 @@ +{ + "availableOptions": ["play", "pause", "previous", "next", "seek"], + "metadata": { + "album": "1 (Remastered)", + "artist": "The Beatles", + "coverArtDataPresent": false, + "coverArtUrl": "https://i.scdn.co/image/ab67616d0000b273582d56ce20fe0146ffa0e5cf", + "duration": 425653, + "mediaType": "unknown", + "title": "Hey Jude - Remastered 2015" + }, + "muteState": "unmuted", + "peerDeviceName": "", + "playingState": "playing", + "source": { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "7b0d8ed0-5650-45cd-841b-647b78730bfb", + "type": "spotifyconnect" + } +} diff --git a/tests/components/devialet/fixtures/sources.json b/tests/components/devialet/fixtures/sources.json new file mode 100644 index 00000000000..5f484314d73 --- /dev/null +++ b/tests/components/devialet/fixtures/sources.json @@ -0,0 +1,41 @@ +{ + "sources": [ + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "7b0d8ed0-5650-45cd-841b-647b78730bfb", + "type": "spotifyconnect" + }, + { + "deviceId": "9abc87d6-ef54-321d-0g9h-ijk876l54m32", + "sourceId": "12708064-01fa-4e25-a0f1-f94b3de49baa", + "streamLockAvailable": false, + "type": "optical" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "82834351-8255-4e2e-9ce2-b7d4da0aa3b0", + "streamLockAvailable": false, + "type": "optical" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "07b1bf6d-9216-4a7b-8d53-5590cee21d90", + "type": "upnp" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "1015e17d-d515-419d-a47b-4a7252bff838", + "type": "airplay2" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "88186c24-f896-4ef0-a731-a6c8f8f01908", + "type": "bluetooth" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "acfd9fe6-7e29-4c2b-b2bd-5083486a5291", + "type": "raat" + } + ] +} diff --git a/tests/components/devialet/fixtures/system_info.json b/tests/components/devialet/fixtures/system_info.json new file mode 100644 index 00000000000..f496e5557d2 --- /dev/null +++ b/tests/components/devialet/fixtures/system_info.json @@ -0,0 +1,6 @@ +{ + "availableFeatures": ["nightMode", "equalizer", "balance"], + "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl", + "systemName": "Devialet" +} diff --git a/tests/components/devialet/fixtures/volume.json b/tests/components/devialet/fixtures/volume.json new file mode 100644 index 00000000000..365d5ed776d --- /dev/null +++ b/tests/components/devialet/fixtures/volume.json @@ -0,0 +1,3 @@ +{ + "volume": 20 +} diff --git a/tests/components/devialet/test_config_flow.py b/tests/components/devialet/test_config_flow.py new file mode 100644 index 00000000000..0bacc558b74 --- /dev/null +++ b/tests/components/devialet/test_config_flow.py @@ -0,0 +1,154 @@ +"""Test the Devialet config flow.""" +from unittest.mock import patch + +from aiohttp import ClientError as HTTPClientError +from devialet.const import UrlSuffix + +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + HOST, + MOCK_USER_INPUT, + MOCK_ZEROCONF_DATA, + NAME, + mock_playing, + setup_integration, +) + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + +async def test_cannot_connect( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we show user form on connection error.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=HTTPClientError + ) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort user flow if DirecTV receiver already configured.""" + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the full manual user flow from start to finish.""" + mock_playing(aioclient_mock) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = MOCK_USER_INPUT.copy() + with patch( + "homeassistant.components.devialet.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + + +async def test_zeroconf_devialet( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we pass Devialet devices to the discovery manager.""" + mock_playing(aioclient_mock) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + + assert result["type"] == "form" + + with patch( + "homeassistant.components.devialet.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Livingroom" + assert result2["data"] == { + CONF_HOST: HOST, + CONF_NAME: NAME, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_async_step_confirm( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test starting a flow from discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=HTTPClientError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT.copy() + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/devialet/test_diagnostics.py b/tests/components/devialet/test_diagnostics.py new file mode 100644 index 00000000000..82600de7cf5 --- /dev/null +++ b/tests/components/devialet/test_diagnostics.py @@ -0,0 +1,40 @@ +"""Test the Devialet diagnostics.""" +import json + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test diagnostics.""" + entry = await setup_integration(hass, aioclient_mock) + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { + "is_available": True, + "general_info": json.loads(load_fixture("general_info.json", "devialet")), + "sources": json.loads(load_fixture("sources.json", "devialet")), + "source_state": json.loads(load_fixture("source_state.json", "devialet")), + "volume": json.loads(load_fixture("volume.json", "devialet")), + "night_mode": json.loads(load_fixture("night_mode.json", "devialet")), + "equalizer": json.loads(load_fixture("equalizer.json", "devialet")), + "source_list": [ + "Airplay", + "Bluetooth", + "Online", + "Optical left", + "Optical right", + "Raat", + "Spotify Connect", + ], + "source": "spotifyconnect", + } diff --git a/tests/components/devialet/test_init.py b/tests/components/devialet/test_init.py new file mode 100644 index 00000000000..86d383e91d8 --- /dev/null +++ b/tests/components/devialet/test_init.py @@ -0,0 +1,49 @@ +"""Test the Devialet init.""" +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerState +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import NAME, setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_load_unload_config_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is not None + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == MediaPlayerState.PLAYING + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_load_unload_config_entry_when_device_unavailable( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading when the device is unavailable.""" + entry = await setup_integration(hass, aioclient_mock, state="unavailable") + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is not None + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == "unavailable" + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py new file mode 100644 index 00000000000..56381bf6de4 --- /dev/null +++ b/tests/components/devialet/test_media_player.py @@ -0,0 +1,312 @@ +"""Test the Devialet init.""" +from unittest.mock import PropertyMock, patch + +from devialet import DevialetApi +from devialet.const import UrlSuffix +from yarl import URL + +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.components.devialet.media_player import SUPPORT_DEVIALET +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, + DOMAIN as MP_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + ATTR_SUPPORTED_FEATURES, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import HOST, NAME, setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + +SERVICE_TO_URL = { + SERVICE_MEDIA_SEEK: [UrlSuffix.SEEK], + SERVICE_MEDIA_PLAY: [UrlSuffix.PLAY], + SERVICE_MEDIA_PAUSE: [UrlSuffix.PAUSE], + SERVICE_MEDIA_STOP: [UrlSuffix.PAUSE], + SERVICE_MEDIA_PREVIOUS_TRACK: [UrlSuffix.PREVIOUS_TRACK], + SERVICE_MEDIA_NEXT_TRACK: [UrlSuffix.NEXT_TRACK], + SERVICE_TURN_OFF: [UrlSuffix.TURN_OFF], + SERVICE_VOLUME_UP: [UrlSuffix.VOLUME_UP], + SERVICE_VOLUME_DOWN: [UrlSuffix.VOLUME_DOWN], + SERVICE_VOLUME_SET: [UrlSuffix.VOLUME_SET], + SERVICE_VOLUME_MUTE: [UrlSuffix.MUTE, UrlSuffix.UNMUTE], + SERVICE_SELECT_SOUND_MODE: [UrlSuffix.EQUALIZER, UrlSuffix.NIGHT_MODE], + SERVICE_SELECT_SOURCE: [ + str(UrlSuffix.SELECT_SOURCE).replace( + "%SOURCE_ID%", "82834351-8255-4e2e-9ce2-b7d4da0aa3b0" + ), + str(UrlSuffix.SELECT_SOURCE).replace( + "%SOURCE_ID%", "07b1bf6d-9216-4a7b-8d53-5590cee21d90" + ), + ], +} + +SERVICE_TO_DATA = { + SERVICE_MEDIA_SEEK: [{"seek_position": 321}], + SERVICE_MEDIA_PLAY: [{}], + SERVICE_MEDIA_PAUSE: [{}], + SERVICE_MEDIA_STOP: [{}], + SERVICE_MEDIA_PREVIOUS_TRACK: [{}], + SERVICE_MEDIA_NEXT_TRACK: [{}], + SERVICE_TURN_OFF: [{}], + SERVICE_VOLUME_UP: [{}], + SERVICE_VOLUME_DOWN: [{}], + SERVICE_VOLUME_SET: [{ATTR_MEDIA_VOLUME_LEVEL: 0.5}], + SERVICE_VOLUME_MUTE: [ + {ATTR_MEDIA_VOLUME_MUTED: True}, + {ATTR_MEDIA_VOLUME_MUTED: False}, + ], + SERVICE_SELECT_SOUND_MODE: [ + {ATTR_SOUND_MODE: "Night mode"}, + {ATTR_SOUND_MODE: "Flat"}, + ], + SERVICE_SELECT_SOURCE: [ + {ATTR_INPUT_SOURCE: "Optical left"}, + {ATTR_INPUT_SOURCE: "Online"}, + ], +} + + +async def test_media_player_playing( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + await async_setup_component(hass, "homeassistant", {}) + entry = await setup_integration(hass, aioclient_mock) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: [f"{MP_DOMAIN}.{NAME.lower()}"]}, + blocking=True, + ) + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == MediaPlayerState.PLAYING + assert state.name == NAME + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2 + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False + assert state.attributes[ATTR_INPUT_SOURCE_LIST] is not None + assert state.attributes[ATTR_SOUND_MODE_LIST] is not None + assert state.attributes[ATTR_MEDIA_ARTIST] == "The Beatles" + assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "1 (Remastered)" + assert state.attributes[ATTR_MEDIA_TITLE] == "Hey Jude - Remastered 2015" + assert state.attributes[ATTR_ENTITY_PICTURE] is not None + assert state.attributes[ATTR_MEDIA_DURATION] == 425653 + assert state.attributes[ATTR_MEDIA_POSITION] == 123102 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] is not None + assert state.attributes[ATTR_SUPPORTED_FEATURES] is not None + assert state.attributes[ATTR_INPUT_SOURCE] is not None + assert state.attributes[ATTR_SOUND_MODE] is not None + + with patch( + "homeassistant.components.devialet.DevialetApi.playing_state", + new_callable=PropertyMock, + ) as mock: + mock.return_value = MediaPlayerState.PAUSED + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").state + == MediaPlayerState.PAUSED + ) + + with patch( + "homeassistant.components.devialet.DevialetApi.playing_state", + new_callable=PropertyMock, + ) as mock: + mock.return_value = MediaPlayerState.ON + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").state == MediaPlayerState.ON + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = None + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = True + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes[ + ATTR_SOUND_MODE + ] + == "Night mode" + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = "unexpected_value" + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = False + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_SOUND_MODE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = None + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = None + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_SOUND_MODE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + with patch.object( + DevialetApi, "available_options", new_callable=PropertyMock + ) as mock: + mock.return_value = None + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes[ + ATTR_SUPPORTED_FEATURES + ] + == SUPPORT_DEVIALET + ) + + with patch.object(DevialetApi, "source", new_callable=PropertyMock) as mock: + mock.return_value = "someSource" + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_INPUT_SOURCE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_offline( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock, state=STATE_UNAVAILABLE) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == STATE_UNAVAILABLE + assert state.name == NAME + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_without_serial( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock, serial=None) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is None + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_services( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet services.""" + entry = await setup_integration( + hass, aioclient_mock, state=MediaPlayerState.PLAYING + ) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + target = {ATTR_ENTITY_ID: hass.states.get(f"{MP_DOMAIN}.{NAME}").entity_id} + + for i, (service, urls) in enumerate(SERVICE_TO_URL.items()): + for url in urls: + aioclient_mock.post(f"http://{HOST}{url}") + + for data_set in list(SERVICE_TO_DATA.values())[i]: + service_data = target.copy() + service_data.update(data_set) + + await hass.services.async_call( + MP_DOMAIN, + service, + service_data=service_data, + blocking=True, + ) + await hass.async_block_till_done() + + for url in urls: + call_available = False + for item in aioclient_mock.mock_calls: + if item[0] == "POST" and item[1] == URL(f"http://{HOST}{url}"): + call_available = True + break + + assert call_available + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED