mirror of https://github.com/home-assistant/core
Add Kaleidescape integration (#67711)
This commit is contained in:
parent
c70bed86ff
commit
ea82f2e293
|
@ -117,6 +117,7 @@ homeassistant.components.isy994.*
|
|||
homeassistant.components.iqvia.*
|
||||
homeassistant.components.jellyfin.*
|
||||
homeassistant.components.jewish_calendar.*
|
||||
homeassistant.components.kaleidescape.*
|
||||
homeassistant.components.knx.*
|
||||
homeassistant.components.kraken.*
|
||||
homeassistant.components.lametric.*
|
||||
|
|
|
@ -513,6 +513,8 @@ tests/components/jewish_calendar/* @tsvi
|
|||
homeassistant/components/juicenet/* @jesserockz
|
||||
tests/components/juicenet/* @jesserockz
|
||||
homeassistant/components/kaiterra/* @Michsior14
|
||||
homeassistant/components/kaleidescape/* @SteveEasley
|
||||
tests/components/kaleidescape/* @SteveEasley
|
||||
homeassistant/components/keba/* @dannerph
|
||||
homeassistant/components/keenetic_ndms2/* @foxel
|
||||
tests/components/keenetic_ndms2/* @foxel
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
"""The Kaleidescape integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaleidescape import Device as KaleidescapeDevice, KaleidescapeError
|
||||
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Kaleidescape from a config entry."""
|
||||
device = KaleidescapeDevice(
|
||||
entry.data[CONF_HOST], timeout=5, reconnect=True, reconnect_delay=5
|
||||
)
|
||||
|
||||
try:
|
||||
await device.connect()
|
||||
except (KaleidescapeError, ConnectionError) as err:
|
||||
await device.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to {entry.data[CONF_HOST]}: {err}"
|
||||
) from err
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device
|
||||
|
||||
async def disconnect(event: Event) -> None:
|
||||
await device.disconnect()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect)
|
||||
)
|
||||
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await hass.data[DOMAIN][entry.entry_id].disconnect()
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
@dataclass
|
||||
class KaleidescapeDeviceInfo:
|
||||
"""Metadata for a Kaleidescape device."""
|
||||
|
||||
host: str
|
||||
serial: str
|
||||
name: str
|
||||
model: str
|
||||
server_only: bool
|
||||
|
||||
|
||||
class UnsupportedError(HomeAssistantError):
|
||||
"""Error for unsupported device types."""
|
||||
|
||||
|
||||
async def validate_host(host: str) -> KaleidescapeDeviceInfo:
|
||||
"""Validate device host."""
|
||||
device = KaleidescapeDevice(host)
|
||||
|
||||
try:
|
||||
await device.connect()
|
||||
except (KaleidescapeError, ConnectionError):
|
||||
await device.disconnect()
|
||||
raise
|
||||
|
||||
info = KaleidescapeDeviceInfo(
|
||||
host=device.host,
|
||||
serial=device.system.serial_number,
|
||||
name=device.system.friendly_name,
|
||||
model=device.system.type,
|
||||
server_only=device.is_server_only,
|
||||
)
|
||||
|
||||
await device.disconnect()
|
||||
|
||||
return info
|
|
@ -0,0 +1,112 @@
|
|||
"""Config flow for Kaleidescape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from . import KaleidescapeDeviceInfo, UnsupportedError, validate_host
|
||||
from .const import DEFAULT_HOST, DOMAIN, NAME as KALEIDESCAPE_NAME
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect"
|
||||
ERROR_UNKNOWN = "unknown"
|
||||
ERROR_UNSUPPORTED = "unsupported"
|
||||
|
||||
|
||||
class KaleidescapeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Kaleidescape integration."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
discovered_device: KaleidescapeDeviceInfo
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle user initiated device additions."""
|
||||
errors = {}
|
||||
host = DEFAULT_HOST
|
||||
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST].strip()
|
||||
|
||||
try:
|
||||
info = await validate_host(host)
|
||||
if info.server_only:
|
||||
raise UnsupportedError
|
||||
except ConnectionError:
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except UnsupportedError:
|
||||
errors["base"] = ERROR_UNSUPPORTED
|
||||
else:
|
||||
host = info.host
|
||||
|
||||
await self.async_set_unique_id(info.serial, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{KALEIDESCAPE_NAME} ({info.name})",
|
||||
data={CONF_HOST: host},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult:
|
||||
"""Handle discovered device."""
|
||||
host = cast(str, urlparse(discovery_info.ssdp_location).hostname)
|
||||
serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]
|
||||
|
||||
await self.async_set_unique_id(serial_number)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
try:
|
||||
self.discovered_device = await validate_host(host)
|
||||
if self.discovered_device.server_only:
|
||||
raise UnsupportedError
|
||||
except ConnectionError:
|
||||
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
|
||||
except UnsupportedError:
|
||||
return self.async_abort(reason=ERROR_UNSUPPORTED)
|
||||
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {
|
||||
"name": self.discovered_device.name,
|
||||
"model": self.discovered_device.model,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle addition of discovered device."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={
|
||||
"name": self.discovered_device.name,
|
||||
"model": self.discovered_device.model,
|
||||
},
|
||||
errors={},
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{KALEIDESCAPE_NAME} ({self.discovered_device.name})",
|
||||
data={CONF_HOST: self.discovered_device.host},
|
||||
)
|
|
@ -0,0 +1,5 @@
|
|||
"""Constants for the Kaleidescape integration."""
|
||||
|
||||
NAME = "Kaleidescape"
|
||||
DOMAIN = "kaleidescape"
|
||||
DEFAULT_HOST = "my-kaleidescape.local"
|
|
@ -0,0 +1,47 @@
|
|||
"""Base Entity for Kaleidescape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
|
||||
from .const import DOMAIN as KALEIDESCAPE_DOMAIN, NAME as KALEIDESCAPE_NAME
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kaleidescape import Device as KaleidescapeDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KaleidescapeEntity(Entity):
|
||||
"""Defines a base Kaleidescape entity."""
|
||||
|
||||
def __init__(self, device: KaleidescapeDevice) -> None:
|
||||
"""Initialize entity."""
|
||||
self._device = device
|
||||
|
||||
self._attr_should_poll = False
|
||||
self._attr_unique_id = device.serial_number
|
||||
self._attr_name = f"{KALEIDESCAPE_NAME} {device.system.friendly_name}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(KALEIDESCAPE_DOMAIN, self._device.serial_number)},
|
||||
name=self.name,
|
||||
model=self._device.system.type,
|
||||
manufacturer=KALEIDESCAPE_NAME,
|
||||
sw_version=f"{self._device.system.kos_version}",
|
||||
suggested_area="Theater",
|
||||
configuration_url=f"http://{self._device.host}",
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register update listener."""
|
||||
|
||||
@callback
|
||||
def _update(event: str) -> None:
|
||||
"""Handle device state changes."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(self._device.dispatcher.connect(_update).disconnect)
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"domain": "kaleidescape",
|
||||
"name": "Kaleidescape",
|
||||
"config_flow": true,
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Kaleidescape, Inc.",
|
||||
"deviceType": "schemas-upnp-org:device:Basic:1"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
|
||||
"requirements": ["pykaleidescape==2022.2.6"],
|
||||
"codeowners": [
|
||||
"@SteveEasley"
|
||||
],
|
||||
"iot_class": "local_push"
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
"""Kaleidescape Media Player."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaleidescape import const as kaleidescape_const
|
||||
|
||||
from homeassistant.components.media_player import MediaPlayerEntity
|
||||
from homeassistant.components.media_player.const import (
|
||||
SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_PAUSE,
|
||||
SUPPORT_PLAY,
|
||||
SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_STOP,
|
||||
SUPPORT_TURN_OFF,
|
||||
SUPPORT_TURN_ON,
|
||||
)
|
||||
from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import DOMAIN as KALEIDESCAPE_DOMAIN
|
||||
from .entity import KaleidescapeEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from kaleidescape import Device as KaleidescapeDevice
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
|
||||
KALEIDESCAPE_PLAYING_STATES = [
|
||||
kaleidescape_const.PLAY_STATUS_PLAYING,
|
||||
kaleidescape_const.PLAY_STATUS_FORWARD,
|
||||
kaleidescape_const.PLAY_STATUS_REVERSE,
|
||||
]
|
||||
|
||||
KALEIDESCAPE_PAUSED_STATES = [kaleidescape_const.PLAY_STATUS_PAUSED]
|
||||
|
||||
SUPPORTED_FEATURES = (
|
||||
SUPPORT_TURN_ON
|
||||
| SUPPORT_TURN_OFF
|
||||
| SUPPORT_PLAY
|
||||
| SUPPORT_PAUSE
|
||||
| SUPPORT_STOP
|
||||
| SUPPORT_NEXT_TRACK
|
||||
| SUPPORT_PREVIOUS_TRACK
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the platform from a config entry."""
|
||||
entities = [KaleidescapeMediaPlayer(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class KaleidescapeMediaPlayer(KaleidescapeEntity, MediaPlayerEntity):
|
||||
"""Representation of a Kaleidescape device."""
|
||||
|
||||
def __init__(self, device: KaleidescapeDevice) -> None:
|
||||
"""Initialize media player."""
|
||||
super().__init__(device)
|
||||
self._attr_supported_features = SUPPORTED_FEATURES
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Send leave standby command."""
|
||||
await self._device.leave_standby()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Send enter standby command."""
|
||||
await self._device.enter_standby()
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self._device.pause()
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._device.play()
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._device.stop()
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send track next command."""
|
||||
await self._device.next()
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send track previous command."""
|
||||
await self._device.previous()
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""State of device."""
|
||||
if self._device.power.state == kaleidescape_const.DEVICE_POWER_STATE_STANDBY:
|
||||
return STATE_OFF
|
||||
if self._device.movie.play_status in KALEIDESCAPE_PLAYING_STATES:
|
||||
return STATE_PLAYING
|
||||
if self._device.movie.play_status in KALEIDESCAPE_PAUSED_STATES:
|
||||
return STATE_PAUSED
|
||||
return STATE_IDLE
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if device is available."""
|
||||
return self._device.is_connected
|
||||
|
||||
@property
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Content ID of current playing media."""
|
||||
if self._device.movie.handle:
|
||||
return self._device.movie.handle
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> str | None:
|
||||
"""Content type of current playing media."""
|
||||
return self._device.movie.media_type
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
"""Duration of current playing media in seconds."""
|
||||
if self._device.movie.title_length:
|
||||
return self._device.movie.title_length
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Position of current playing media in seconds."""
|
||||
if self._device.movie.title_location:
|
||||
return self._device.movie.title_location
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""When was the position of the current playing media valid."""
|
||||
if self._device.movie.play_status in KALEIDESCAPE_PLAYING_STATES:
|
||||
return utcnow()
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str:
|
||||
"""Image url of current playing media."""
|
||||
return self._device.movie.cover
|
||||
|
||||
@property
|
||||
def media_title(self) -> str:
|
||||
"""Title of current playing media."""
|
||||
return self._device.movie.title
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"config": {
|
||||
"flow_title": "{model} ({name})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Kaleidescape Setup",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"title": "Kaleidescape",
|
||||
"description": "Do you want to set up the {model} player named {name}?"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unsupported": "Unsupported device"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unsupported": "Unsupported device"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"config": {
|
||||
"flow_title": "{model} ({name})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Kaleidescape Setup",
|
||||
"data": {
|
||||
"host": "Host"
|
||||
}
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"title": "Kaleidescape Setup",
|
||||
"description": "Do you want to set up the {model} player named {name}?"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"already_in_progress": "Configuration flow is already in progress",
|
||||
"unknown": "Unexpected error",
|
||||
"unsupported": "Unsupported device"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"unsupported": "Unsupported device"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -167,6 +167,7 @@ FLOWS = [
|
|||
"izone",
|
||||
"jellyfin",
|
||||
"juicenet",
|
||||
"kaleidescape",
|
||||
"keenetic_ndms2",
|
||||
"kmtronic",
|
||||
"knx",
|
||||
|
|
|
@ -168,6 +168,12 @@ SSDP = {
|
|||
"manufacturer": "Universal Devices Inc."
|
||||
}
|
||||
],
|
||||
"kaleidescape": [
|
||||
{
|
||||
"deviceType": "schemas-upnp-org:device:Basic:1",
|
||||
"manufacturer": "Kaleidescape, Inc."
|
||||
}
|
||||
],
|
||||
"keenetic_ndms2": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1",
|
||||
|
|
11
mypy.ini
11
mypy.ini
|
@ -1088,6 +1088,17 @@ no_implicit_optional = true
|
|||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.kaleidescape.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.knx.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
|
|
@ -1560,6 +1560,9 @@ pyisy==3.0.1
|
|||
# homeassistant.components.itach
|
||||
pyitachip2ir==0.0.7
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==2022.2.6
|
||||
|
||||
# homeassistant.components.kira
|
||||
pykira==0.1.1
|
||||
|
||||
|
|
|
@ -1013,6 +1013,9 @@ pyiss==1.0.1
|
|||
# homeassistant.components.isy994
|
||||
pyisy==3.0.1
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==2022.2.6
|
||||
|
||||
# homeassistant.components.kira
|
||||
pykira==0.1.1
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
"""Tests for Kaleidescape integration."""
|
||||
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL
|
||||
|
||||
MOCK_HOST = "127.0.0.1"
|
||||
MOCK_SERIAL = "123456"
|
||||
MOCK_NAME = "Theater"
|
||||
|
||||
MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo(
|
||||
ssdp_usn="mock_usn",
|
||||
ssdp_st="mock_st",
|
||||
ssdp_location=f"http://{MOCK_HOST}",
|
||||
upnp={
|
||||
ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME,
|
||||
ATTR_UPNP_SERIAL: MOCK_SERIAL,
|
||||
},
|
||||
)
|
|
@ -0,0 +1,73 @@
|
|||
"""Fixtures for Kaleidescape integration."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from kaleidescape import Dispatcher
|
||||
from kaleidescape.device import Automation, Movie, Power, System
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.kaleidescape.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MOCK_HOST, MOCK_SERIAL
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_device")
|
||||
def fixture_mock_device() -> Generator[None, AsyncMock, None]:
|
||||
"""Return a mocked Kaleidescape device."""
|
||||
with patch(
|
||||
"homeassistant.components.kaleidescape.KaleidescapeDevice", autospec=True
|
||||
) as mock:
|
||||
host = MOCK_HOST
|
||||
|
||||
device = mock.return_value
|
||||
device.dispatcher = Dispatcher()
|
||||
device.host = host
|
||||
device.port = 10000
|
||||
device.serial_number = MOCK_SERIAL
|
||||
device.is_connected = True
|
||||
device.is_server_only = False
|
||||
device.is_movie_player = True
|
||||
device.is_music_player = False
|
||||
device.system = System(
|
||||
ip_address=host,
|
||||
serial_number=MOCK_SERIAL,
|
||||
type="Strato",
|
||||
protocol=16,
|
||||
kos_version="10.4.2-19218",
|
||||
friendly_name=f"Device {MOCK_SERIAL}",
|
||||
movie_zones=1,
|
||||
music_zones=1,
|
||||
)
|
||||
device.power = Power(state="standby", readiness="disabled", zone=["available"])
|
||||
device.movie = Movie()
|
||||
device.automation = Automation()
|
||||
|
||||
yield device
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_config_entry")
|
||||
def fixture_mock_config_entry() -> MockConfigEntry:
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=MOCK_SERIAL,
|
||||
version=1,
|
||||
data={CONF_HOST: MOCK_HOST},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_integration")
|
||||
async def fixture_mock_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> MockConfigEntry:
|
||||
"""Return a mock ConfigEntry setup for Kaleidescape integration."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
|
@ -0,0 +1,130 @@
|
|||
"""Tests for Kaleidescape config flow."""
|
||||
|
||||
import dataclasses
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from homeassistant.components.kaleidescape.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import (
|
||||
RESULT_TYPE_ABORT,
|
||||
RESULT_TYPE_CREATE_ENTRY,
|
||||
RESULT_TYPE_FORM,
|
||||
)
|
||||
|
||||
from . import MOCK_HOST, MOCK_SSDP_DISCOVERY_INFO
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_user_config_flow_success(
|
||||
hass: HomeAssistant, mock_device: AsyncMock
|
||||
) -> None:
|
||||
"""Test user config flow success."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: MOCK_HOST}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert "data" in result
|
||||
assert result["data"][CONF_HOST] == MOCK_HOST
|
||||
|
||||
|
||||
async def test_user_config_flow_bad_connect_errors(
|
||||
hass: HomeAssistant, mock_device: AsyncMock
|
||||
) -> None:
|
||||
"""Test errors when connection error occurs."""
|
||||
mock_device.connect.side_effect = ConnectionError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: MOCK_HOST}
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_user_config_flow_unsupported_device_errors(
|
||||
hass: HomeAssistant, mock_device: AsyncMock
|
||||
) -> None:
|
||||
"""Test errors when connecting to unsupported device."""
|
||||
mock_device.is_server_only = True
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: MOCK_HOST}
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "unsupported"}
|
||||
|
||||
|
||||
async def test_user_config_flow_device_exists_abort(
|
||||
hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test flow aborts when device already configured."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: MOCK_HOST}
|
||||
)
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_ssdp_config_flow_success(
|
||||
hass: HomeAssistant, mock_device: AsyncMock
|
||||
) -> None:
|
||||
"""Test ssdp config flow success."""
|
||||
discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_info
|
||||
)
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert "data" in result
|
||||
assert result["data"][CONF_HOST] == MOCK_HOST
|
||||
|
||||
|
||||
async def test_ssdp_config_flow_bad_connect_aborts(
|
||||
hass: HomeAssistant, mock_device: AsyncMock
|
||||
) -> None:
|
||||
"""Test abort when connection error occurs."""
|
||||
mock_device.connect.side_effect = ConnectionError
|
||||
|
||||
discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_info
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_ssdp_config_flow_unsupported_device_aborts(
|
||||
hass: HomeAssistant, mock_device: AsyncMock
|
||||
) -> None:
|
||||
"""Test abort when connecting to unsupported device."""
|
||||
mock_device.is_server_only = True
|
||||
|
||||
discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_info
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "unsupported"
|
|
@ -0,0 +1,58 @@
|
|||
"""Tests for Kaleidescape config entry."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from homeassistant.components.kaleidescape.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MOCK_SERIAL
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_unload_config_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_device: AsyncMock,
|
||||
mock_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config entry loading and unloading."""
|
||||
mock_config_entry = mock_integration
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert mock_device.connect.call_count == 1
|
||||
assert mock_device.disconnect.call_count == 0
|
||||
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_device.disconnect.call_count == 1
|
||||
assert mock_config_entry.entry_id not in hass.data[DOMAIN]
|
||||
|
||||
|
||||
async def test_config_entry_not_ready(
|
||||
hass: HomeAssistant,
|
||||
mock_device: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config entry not ready."""
|
||||
mock_device.connect.side_effect = ConnectionError
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_device(
|
||||
hass: HomeAssistant,
|
||||
mock_device: AsyncMock,
|
||||
mock_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test device."""
|
||||
device_registry = await hass.helpers.device_registry.async_get_registry()
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={("kaleidescape", MOCK_SERIAL)}
|
||||
)
|
||||
assert device is not None
|
||||
assert device.identifiers == {("kaleidescape", MOCK_SERIAL)}
|
|
@ -0,0 +1,183 @@
|
|||
"""Tests for Kaleidescape media player platform."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from kaleidescape import const as kaleidescape_const
|
||||
from kaleidescape.device import Movie
|
||||
|
||||
from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_MEDIA_NEXT_TRACK,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
SERVICE_MEDIA_STOP,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_IDLE,
|
||||
STATE_OFF,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MOCK_SERIAL
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
ENTITY_ID = f"media_player.kaleidescape_device_{MOCK_SERIAL}"
|
||||
FRIENDLY_NAME = f"Kaleidescape Device {MOCK_SERIAL}"
|
||||
|
||||
|
||||
async def test_entity(
|
||||
hass: HomeAssistant,
|
||||
mock_device: MagicMock,
|
||||
mock_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test entity attributes."""
|
||||
entity = hass.states.get(ENTITY_ID)
|
||||
assert entity is not None
|
||||
assert entity.state == STATE_OFF
|
||||
assert entity.attributes["friendly_name"] == FRIENDLY_NAME
|
||||
|
||||
|
||||
async def test_update_state(
|
||||
hass: HomeAssistant,
|
||||
mock_device: MagicMock,
|
||||
mock_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Tests dispatched signals update player."""
|
||||
entity = hass.states.get(ENTITY_ID)
|
||||
assert entity is not None
|
||||
assert entity.state == STATE_OFF
|
||||
|
||||
# Device turns on
|
||||
mock_device.power.state = kaleidescape_const.DEVICE_POWER_STATE_ON
|
||||
mock_device.dispatcher.send(kaleidescape_const.DEVICE_POWER_STATE)
|
||||
await hass.async_block_till_done()
|
||||
entity = hass.states.get(ENTITY_ID)
|
||||
assert entity is not None
|
||||
assert entity.state == STATE_IDLE
|
||||
|
||||
# Devices starts playing
|
||||
mock_device.movie = Movie(
|
||||
handle="handle",
|
||||
title="title",
|
||||
cover="cover",
|
||||
cover_hires="cover_hires",
|
||||
rating="rating",
|
||||
rating_reason="rating_reason",
|
||||
year="year",
|
||||
runtime="runtime",
|
||||
actors=[],
|
||||
director="director",
|
||||
directors=[],
|
||||
genre="genre",
|
||||
genres=[],
|
||||
synopsis="synopsis",
|
||||
color="color",
|
||||
country="country",
|
||||
aspect_ratio="aspect_ratio",
|
||||
media_type="media_type",
|
||||
play_status=kaleidescape_const.PLAY_STATUS_PLAYING,
|
||||
play_speed=1,
|
||||
title_number=1,
|
||||
title_length=1,
|
||||
title_location=1,
|
||||
chapter_number=1,
|
||||
chapter_length=1,
|
||||
chapter_location=1,
|
||||
)
|
||||
mock_device.dispatcher.send(kaleidescape_const.PLAY_STATUS)
|
||||
await hass.async_block_till_done()
|
||||
entity = hass.states.get(ENTITY_ID)
|
||||
assert entity is not None
|
||||
assert entity.state == STATE_PLAYING
|
||||
|
||||
# Devices pauses playing
|
||||
mock_device.movie.play_status = kaleidescape_const.PLAY_STATUS_PAUSED
|
||||
mock_device.dispatcher.send(kaleidescape_const.PLAY_STATUS)
|
||||
await hass.async_block_till_done()
|
||||
entity = hass.states.get(ENTITY_ID)
|
||||
assert entity is not None
|
||||
assert entity.state == STATE_PAUSED
|
||||
|
||||
|
||||
async def test_services(
|
||||
hass: HomeAssistant,
|
||||
mock_device: MagicMock,
|
||||
mock_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test service calls."""
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_device.leave_standby.call_count == 1
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_device.enter_standby.call_count == 1
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_device.play.call_count == 1
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_device.pause.call_count == 1
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_STOP,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_device.stop.call_count == 1
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_NEXT_TRACK,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_device.next.call_count == 1
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_device.previous.call_count == 1
|
||||
|
||||
|
||||
async def test_device(
|
||||
hass: HomeAssistant,
|
||||
mock_device: MagicMock,
|
||||
mock_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test device attributes."""
|
||||
device_registry = await hass.helpers.device_registry.async_get_registry()
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={("kaleidescape", MOCK_SERIAL)}
|
||||
)
|
||||
assert device.name == FRIENDLY_NAME
|
||||
assert device.model == "Strato"
|
||||
assert device.sw_version == "10.4.2-19218"
|
||||
assert device.manufacturer == "Kaleidescape"
|
Loading…
Reference in New Issue