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 datapull/104647/head
parent
61a5c0de5e
commit
d3b04a5a58
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
)
|
|
@ -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",
|
||||
}
|
|
@ -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()
|
|
@ -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()
|
|
@ -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."]
|
||||
}
|
|
@ -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)
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -95,6 +95,7 @@ FLOWS = {
|
|||
"deconz",
|
||||
"deluge",
|
||||
"denonavr",
|
||||
"devialet",
|
||||
"devolo_home_control",
|
||||
"devolo_home_network",
|
||||
"dexcom",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -364,6 +364,11 @@ ZEROCONF = {
|
|||
"domain": "forked_daapd",
|
||||
},
|
||||
],
|
||||
"_devialet-http._tcp.local.": [
|
||||
{
|
||||
"domain": "devialet",
|
||||
},
|
||||
],
|
||||
"_dkapi._tcp.local.": [
|
||||
{
|
||||
"domain": "daikin",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"position": 123102
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"nightMode": "off"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"error": {
|
||||
"code": "NoCurrentSource",
|
||||
"details": {},
|
||||
"message": ""
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"availableFeatures": ["nightMode", "equalizer", "balance"],
|
||||
"groupId": "12345678-901a-2b3c-def4-567g89h0i12j",
|
||||
"systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl",
|
||||
"systemName": "Devialet"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"volume": 20
|
||||
}
|
|
@ -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"}
|
|
@ -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",
|
||||
}
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue