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
pull/104647/head
fwestenberg 2023-11-28 13:56:17 +01:00 committed by GitHub
parent 61a5c0de5e
commit d3b04a5a58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1317 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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",
}

View File

@ -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()

View File

@ -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()

View File

@ -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."]
}

View File

@ -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)

View File

@ -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%]"
}
}
}

View File

@ -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."
}
}
}
}

View File

@ -95,6 +95,7 @@ FLOWS = {
"deconz",
"deluge",
"denonavr",
"devialet",
"devolo_home_control",
"devolo_home_network",
"dexcom",

View File

@ -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",

View File

@ -364,6 +364,11 @@ ZEROCONF = {
"domain": "forked_daapd",
},
],
"_devialet-http._tcp.local.": [
{
"domain": "devialet",
},
],
"_dkapi._tcp.local.": [
{
"domain": "daikin",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
{
"position": 123102
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -0,0 +1,3 @@
{
"nightMode": "off"
}

View File

@ -0,0 +1,7 @@
{
"error": {
"code": "NoCurrentSource",
"details": {},
"message": ""
}
}

View File

@ -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"
}
}

View File

@ -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"
}
]
}

View File

@ -0,0 +1,6 @@
{
"availableFeatures": ["nightMode", "equalizer", "balance"],
"groupId": "12345678-901a-2b3c-def4-567g89h0i12j",
"systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl",
"systemName": "Devialet"
}

View File

@ -0,0 +1,3 @@
{
"volume": 20
}

View File

@ -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"}

View File

@ -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",
}

View File

@ -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

View File

@ -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