From ea82f2e293f43d3e5be103a64b68d088c4b65545 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Mon, 7 Mar 2022 15:16:43 -0800 Subject: [PATCH] Add Kaleidescape integration (#67711) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/kaleidescape/__init__.py | 96 +++++++++ .../components/kaleidescape/config_flow.py | 112 +++++++++++ .../components/kaleidescape/const.py | 5 + .../components/kaleidescape/entity.py | 47 +++++ .../components/kaleidescape/manifest.json | 17 ++ .../components/kaleidescape/media_player.py | 158 +++++++++++++++ .../components/kaleidescape/strings.json | 27 +++ .../kaleidescape/translations/en.json | 27 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 6 + mypy.ini | 11 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/kaleidescape/__init__.py | 18 ++ tests/components/kaleidescape/conftest.py | 73 +++++++ .../kaleidescape/test_config_flow.py | 130 +++++++++++++ tests/components/kaleidescape/test_init.py | 58 ++++++ .../kaleidescape/test_media_player.py | 183 ++++++++++++++++++ 20 files changed, 978 insertions(+) create mode 100644 homeassistant/components/kaleidescape/__init__.py create mode 100644 homeassistant/components/kaleidescape/config_flow.py create mode 100644 homeassistant/components/kaleidescape/const.py create mode 100644 homeassistant/components/kaleidescape/entity.py create mode 100644 homeassistant/components/kaleidescape/manifest.json create mode 100644 homeassistant/components/kaleidescape/media_player.py create mode 100644 homeassistant/components/kaleidescape/strings.json create mode 100644 homeassistant/components/kaleidescape/translations/en.json create mode 100644 tests/components/kaleidescape/__init__.py create mode 100644 tests/components/kaleidescape/conftest.py create mode 100644 tests/components/kaleidescape/test_config_flow.py create mode 100644 tests/components/kaleidescape/test_init.py create mode 100644 tests/components/kaleidescape/test_media_player.py diff --git a/.strict-typing b/.strict-typing index 6796c5f3866..4e5a2da5b79 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 79a9241955e..bc84567a2b0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/kaleidescape/__init__.py b/homeassistant/components/kaleidescape/__init__.py new file mode 100644 index 00000000000..574e74a3e14 --- /dev/null +++ b/homeassistant/components/kaleidescape/__init__.py @@ -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 diff --git a/homeassistant/components/kaleidescape/config_flow.py b/homeassistant/components/kaleidescape/config_flow.py new file mode 100644 index 00000000000..a6127e89a77 --- /dev/null +++ b/homeassistant/components/kaleidescape/config_flow.py @@ -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}, + ) diff --git a/homeassistant/components/kaleidescape/const.py b/homeassistant/components/kaleidescape/const.py new file mode 100644 index 00000000000..dc4e0195977 --- /dev/null +++ b/homeassistant/components/kaleidescape/const.py @@ -0,0 +1,5 @@ +"""Constants for the Kaleidescape integration.""" + +NAME = "Kaleidescape" +DOMAIN = "kaleidescape" +DEFAULT_HOST = "my-kaleidescape.local" diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py new file mode 100644 index 00000000000..9a5e62bca94 --- /dev/null +++ b/homeassistant/components/kaleidescape/entity.py @@ -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) diff --git a/homeassistant/components/kaleidescape/manifest.json b/homeassistant/components/kaleidescape/manifest.json new file mode 100644 index 00000000000..88d5c7726f0 --- /dev/null +++ b/homeassistant/components/kaleidescape/manifest.json @@ -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" +} \ No newline at end of file diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py new file mode 100644 index 00000000000..080db5524fe --- /dev/null +++ b/homeassistant/components/kaleidescape/media_player.py @@ -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 diff --git a/homeassistant/components/kaleidescape/strings.json b/homeassistant/components/kaleidescape/strings.json new file mode 100644 index 00000000000..07c5d5bafd9 --- /dev/null +++ b/homeassistant/components/kaleidescape/strings.json @@ -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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kaleidescape/translations/en.json b/homeassistant/components/kaleidescape/translations/en.json new file mode 100644 index 00000000000..38dd84843c3 --- /dev/null +++ b/homeassistant/components/kaleidescape/translations/en.json @@ -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" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 968a52d8269..195f09c91a1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -167,6 +167,7 @@ FLOWS = [ "izone", "jellyfin", "juicenet", + "kaleidescape", "keenetic_ndms2", "kmtronic", "knx", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index f0117e2a9c2..40bb9bf295f 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -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", diff --git a/mypy.ini b/mypy.ini index 2bb0f5b5462..c3f6540273e 100644 --- a/mypy.ini +++ b/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 diff --git a/requirements_all.txt b/requirements_all.txt index 848252818f7..876aa8bbdd2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a27eda2584a..9ec49746695 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/kaleidescape/__init__.py b/tests/components/kaleidescape/__init__.py new file mode 100644 index 00000000000..8182cb73743 --- /dev/null +++ b/tests/components/kaleidescape/__init__.py @@ -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, + }, +) diff --git a/tests/components/kaleidescape/conftest.py b/tests/components/kaleidescape/conftest.py new file mode 100644 index 00000000000..c86d8f2ccd0 --- /dev/null +++ b/tests/components/kaleidescape/conftest.py @@ -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 diff --git a/tests/components/kaleidescape/test_config_flow.py b/tests/components/kaleidescape/test_config_flow.py new file mode 100644 index 00000000000..a2cf8091d02 --- /dev/null +++ b/tests/components/kaleidescape/test_config_flow.py @@ -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" diff --git a/tests/components/kaleidescape/test_init.py b/tests/components/kaleidescape/test_init.py new file mode 100644 index 00000000000..876c02ba5a6 --- /dev/null +++ b/tests/components/kaleidescape/test_init.py @@ -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)} diff --git a/tests/components/kaleidescape/test_media_player.py b/tests/components/kaleidescape/test_media_player.py new file mode 100644 index 00000000000..94ba7f82fe8 --- /dev/null +++ b/tests/components/kaleidescape/test_media_player.py @@ -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"