From 6bbcf2f689cb1da55d2f5159869049188e2971d9 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Fri, 5 May 2023 14:44:53 -0400 Subject: [PATCH] Add JVC Projector integration (#84748) * Initial commit of jvcprojector * Renamed domain * Initial commit * Support for v1.0.6 device api * Fixed failing test * Removed TYPE_CHECKING constant * Removed jvc brand * Removed constant rename * Renaming more constants * Renaming yet more constants * Improved config_flow tests * More changes based on feedback * Moved config_flow dependency * Removed default translation title * Removed translation file * Order manifest properly --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/jvc_projector/__init__.py | 65 ++++ .../components/jvc_projector/config_flow.py | 129 ++++++++ .../components/jvc_projector/const.py | 5 + .../components/jvc_projector/coordinator.py | 62 ++++ .../components/jvc_projector/entity.py | 38 +++ .../components/jvc_projector/manifest.json | 11 + .../components/jvc_projector/remote.py | 76 +++++ .../components/jvc_projector/strings.json | 35 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/jvc_projector/__init__.py | 7 + tests/components/jvc_projector/conftest.py | 58 ++++ .../jvc_projector/test_config_flow.py | 297 ++++++++++++++++++ .../jvc_projector/test_coordinator.py | 73 +++++ tests/components/jvc_projector/test_init.py | 71 +++++ tests/components/jvc_projector/test_remote.py | 77 +++++ 21 files changed, 1030 insertions(+) create mode 100644 homeassistant/components/jvc_projector/__init__.py create mode 100644 homeassistant/components/jvc_projector/config_flow.py create mode 100644 homeassistant/components/jvc_projector/const.py create mode 100644 homeassistant/components/jvc_projector/coordinator.py create mode 100644 homeassistant/components/jvc_projector/entity.py create mode 100644 homeassistant/components/jvc_projector/manifest.json create mode 100644 homeassistant/components/jvc_projector/remote.py create mode 100644 homeassistant/components/jvc_projector/strings.json create mode 100644 tests/components/jvc_projector/__init__.py create mode 100644 tests/components/jvc_projector/conftest.py create mode 100644 tests/components/jvc_projector/test_config_flow.py create mode 100644 tests/components/jvc_projector/test_coordinator.py create mode 100644 tests/components/jvc_projector/test_init.py create mode 100644 tests/components/jvc_projector/test_remote.py diff --git a/.strict-typing b/.strict-typing index cfc9d741ca7..caab7641a89 100644 --- a/.strict-typing +++ b/.strict-typing @@ -178,6 +178,7 @@ homeassistant.components.iqvia.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* +homeassistant.components.jvc_projector.* homeassistant.components.kaleidescape.* homeassistant.components.knx.* homeassistant.components.kraken.* diff --git a/CODEOWNERS b/CODEOWNERS index ca0475f25ee..e3d53d903e6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -608,6 +608,8 @@ build.json @home-assistant/supervisor /tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen +/homeassistant/components/jvc_projector/ @SteveEasley +/tests/components/jvc_projector/ @SteveEasley /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py new file mode 100644 index 00000000000..996d745a1d5 --- /dev/null +++ b/homeassistant/components/jvc_projector/__init__.py @@ -0,0 +1,65 @@ +"""The jvc_projector integration.""" + +from __future__ import annotations + +from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import JvcProjectorDataUpdateCoordinator + +PLATFORMS = [Platform.REMOTE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up integration from a config entry.""" + device = JvcProjector( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + password=entry.data[CONF_PASSWORD], + ) + + try: + await device.connect(True) + except JvcProjectorConnectError as err: + await device.disconnect() + raise ConfigEntryNotReady( + f"Unable to connect to {entry.data[CONF_HOST]}" + ) from err + except JvcProjectorAuthError as err: + await device.disconnect() + raise ConfigEntryAuthFailed("Password authentication failed") from err + + coordinator = JvcProjectorDataUpdateCoordinator(hass, device) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + async def disconnect(event: Event) -> None: + await device.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) + ) + + await hass.config_entries.async_forward_entry_setups(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].device.disconnect() + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/jvc_projector/config_flow.py b/homeassistant/components/jvc_projector/config_flow.py new file mode 100644 index 00000000000..181d11e1f56 --- /dev/null +++ b/homeassistant/components/jvc_projector/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for the jvc_projector integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError +from jvcprojector.projector import DEFAULT_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac +from homeassistant.util.network import is_host_valid + +from .const import DOMAIN, NAME + + +class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the JVC Projector integration.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user initiated device additions.""" + errors = {} + + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + password = user_input.get(CONF_PASSWORD) + + try: + if not is_host_valid(host): + raise InvalidHost + + mac = await get_mac_address(host, port, password) + except InvalidHost: + errors["base"] = "invalid_host" + except JvcProjectorConnectError: + errors["base"] = "cannot_connect" + except JvcProjectorAuthError: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password} + ) + + return self.async_create_entry( + title=NAME, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth on password authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self._reauth_entry + + errors = {} + + if user_input is not None: + host = self._reauth_entry.data[CONF_HOST] + port = self._reauth_entry.data[CONF_PORT] + password = user_input[CONF_PASSWORD] + + try: + await get_mac_address(host, port, password) + except JvcProjectorConnectError: + errors["base"] = "cannot_connect" + except JvcProjectorAuthError: + errors["base"] = "invalid_auth" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}), + errors=errors, + ) + + +class InvalidHost(Exception): + """Error indicating invalid network host.""" + + +async def get_mac_address(host: str, port: int, password: str | None) -> str: + """Get device mac address for config flow.""" + device = JvcProjector(host, port=port, password=password) + try: + await device.connect(True) + finally: + await device.disconnect() + return device.mac diff --git a/homeassistant/components/jvc_projector/const.py b/homeassistant/components/jvc_projector/const.py new file mode 100644 index 00000000000..e15aa93bfa5 --- /dev/null +++ b/homeassistant/components/jvc_projector/const.py @@ -0,0 +1,5 @@ +"""Constants for the jvc_projector integration.""" + +NAME = "JVC Projector" +DOMAIN = "jvc_projector" +MANUFACTURER = "JVC" diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py new file mode 100644 index 00000000000..a63d68781b3 --- /dev/null +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -0,0 +1,62 @@ +"""Data update coordinator for the jvc_projector integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from jvcprojector import ( + JvcProjector, + JvcProjectorAuthError, + JvcProjectorConnectError, + const, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import NAME + +_LOGGER = logging.getLogger(__name__) + +INTERVAL_SLOW = timedelta(seconds=60) +INTERVAL_FAST = timedelta(seconds=6) + + +class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Data update coordinator for the JVC Projector integration.""" + + def __init__(self, hass: HomeAssistant, device: JvcProjector) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=NAME, + update_interval=INTERVAL_SLOW, + ) + + self.device = device + self.unique_id = format_mac(device.mac) + + async def _async_update_data(self) -> dict[str, str]: + """Get the latest state data.""" + try: + state = await self.device.get_state() + except JvcProjectorConnectError as err: + raise UpdateFailed(f"Unable to connect to {self.device.host}") from err + except JvcProjectorAuthError as err: + raise ConfigEntryAuthFailed("Password authentication failed") from err + + old_interval = self.update_interval + + if state[const.POWER] != const.STANDBY: + self.update_interval = INTERVAL_FAST + else: + self.update_interval = INTERVAL_SLOW + + if self.update_interval != old_interval: + _LOGGER.debug("Changed update interval to %s", self.update_interval) + + return state diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py new file mode 100644 index 00000000000..5d1821c6b56 --- /dev/null +++ b/homeassistant/components/jvc_projector/entity.py @@ -0,0 +1,38 @@ +"""Base Entity for the jvc_projector integration.""" + +from __future__ import annotations + +import logging + +from jvcprojector import JvcProjector + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, NAME +from .coordinator import JvcProjectorDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]): + """Defines a base JVC Projector entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: JvcProjectorDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.unique_id)}, + name=NAME, + model=self.device.model, + manufacturer=MANUFACTURER, + ) + + @property + def device(self) -> JvcProjector: + """Return the device representing the projector.""" + return self.coordinator.device diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json new file mode 100644 index 00000000000..bc01da5d89a --- /dev/null +++ b/homeassistant/components/jvc_projector/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "jvc_projector", + "name": "JVC Projector", + "codeowners": ["@SteveEasley"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/jvc_projector", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["jvcprojector"], + "requirements": ["pyjvcprojector==1.0.6"] +} diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py new file mode 100644 index 00000000000..e33eef74c48 --- /dev/null +++ b/homeassistant/components/jvc_projector/remote.py @@ -0,0 +1,76 @@ +"""Remote platform for the jvc_projector integration.""" + +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import Any + +from jvcprojector import const + +from homeassistant.components.remote import RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import JvcProjectorEntity + +COMMANDS = { + "menu": const.REMOTE_MENU, + "up": const.REMOTE_UP, + "down": const.REMOTE_DOWN, + "left": const.REMOTE_LEFT, + "right": const.REMOTE_RIGHT, + "ok": const.REMOTE_OK, + "back": const.REMOTE_BACK, + "mpc": const.REMOTE_MPC, + "hide": const.REMOTE_HIDE, + "info": const.REMOTE_INFO, + "input": const.REMOTE_INPUT, + "cmd": const.REMOTE_CMD, + "advanced_menu": const.REMOTE_ADVANCED_MENU, + "picture_mode": const.REMOTE_PICTURE_MODE, + "color_profile": const.REMOTE_COLOR_PROFILE, + "lens_control": const.REMOTE_LENS_CONTROL, + "setting_memory": const.REMOTE_SETTING_MEMORY, + "gamma_settings": const.REMOTE_GAMMA_SETTINGS, +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the JVC Projector platform from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([JvcProjectorRemote(coordinator)], True) + + +class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): + """Representation of a JVC Projector device.""" + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.coordinator.data["power"] in [const.ON, const.WARMING] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.device.power_on() + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.device.power_off() + await self.coordinator.async_refresh() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a remote command to the device.""" + for cmd in command: + if cmd not in COMMANDS: + raise HomeAssistantError(f"{cmd} is not a known command") + _LOGGER.debug("Sending command '%s'", cmd) + await self.device.remote(COMMANDS[cmd]) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json new file mode 100644 index 00000000000..11e2f66f91e --- /dev/null +++ b/homeassistant/components/jvc_projector/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "IP address or hostname of projector", + "port": "IP port of projector (default is 20554)", + "password": "Optional password if projector is configured for one" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Password authentication failed", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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%]" + }, + "error": { + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Password authentication failed" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 066fb6fb8b0..9506be28872 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -214,6 +214,7 @@ FLOWS = { "jellyfin", "juicenet", "justnimbus", + "jvc_projector", "kaleidescape", "keenetic_ndms2", "kegtron", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b8d8b207991..22b89430ed6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2658,6 +2658,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "jvc_projector": { + "name": "JVC Projector", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "kaiterra": { "name": "Kaiterra", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 6f1082aad71..7f8ae23d38d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1542,6 +1542,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.jvc_projector.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.kaleidescape.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e41fe759434..8022752c697 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1709,6 +1709,9 @@ pyisy==3.1.14 # homeassistant.components.itach pyitachip2ir==0.0.7 +# homeassistant.components.jvc_projector +pyjvcprojector==1.0.6 + # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec099241382..65a97d8ebec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1243,6 +1243,9 @@ pyiss==1.0.1 # homeassistant.components.isy994 pyisy==3.1.14 +# homeassistant.components.jvc_projector +pyjvcprojector==1.0.6 + # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/tests/components/jvc_projector/__init__.py b/tests/components/jvc_projector/__init__.py new file mode 100644 index 00000000000..d8554e8f4cd --- /dev/null +++ b/tests/components/jvc_projector/__init__.py @@ -0,0 +1,7 @@ +"""Tests for JVC Projector integration.""" + +MOCK_HOST = "127.0.0.1" +MOCK_PORT = 20554 +MOCK_PASSWORD = "jvcpasswd" +MOCK_MAC = "jvcmac" +MOCK_MODEL = "jvcmodel" diff --git a/tests/components/jvc_projector/conftest.py b/tests/components/jvc_projector/conftest.py new file mode 100644 index 00000000000..091aad9e849 --- /dev/null +++ b/tests/components/jvc_projector/conftest.py @@ -0,0 +1,58 @@ +"""Fixtures for JVC Projector integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant + +from . import MOCK_HOST, MOCK_MAC, MOCK_MODEL, MOCK_PASSWORD, MOCK_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_device") +def fixture_mock_device(request) -> Generator[None, AsyncMock, None]: + """Return a mocked JVC Projector device.""" + target = "homeassistant.components.jvc_projector.JvcProjector" + if hasattr(request, "param"): + target = request.param + + with patch(target, autospec=True) as mock: + device = mock.return_value + device.host = MOCK_HOST + device.port = MOCK_PORT + device.mac = MOCK_MAC + device.model = MOCK_MODEL + device.get_state.return_value = {"power": "standby"} + 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_MAC, + version=1, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + +@pytest.fixture(name="mock_integration") +async def fixture_mock_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Return a mock ConfigEntry setup for the 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/jvc_projector/test_config_flow.py b/tests/components/jvc_projector/test_config_flow.py new file mode 100644 index 00000000000..a35dcd1ca38 --- /dev/null +++ b/tests/components/jvc_projector/test_config_flow.py @@ -0,0 +1,297 @@ +"""Tests for JVC Projector config flow.""" + +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError +import pytest + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import MOCK_HOST, MOCK_PASSWORD, MOCK_PORT + +from tests.common import MockConfigEntry + +TARGET = "homeassistant.components.jvc_projector.config_flow.JvcProjector" + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +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"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +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 = JvcProjectorConnectError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +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, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_bad_host_errors( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test errors when bad host error occurs.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "", CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # Finish flow with success + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_bad_auth_errors( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test errors when bad auth error occurs.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_success( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_auth_error( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow when connect fails.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_connect_error( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow when connect fails.""" + mock_device.connect.side_effect = JvcProjectorConnectError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD diff --git a/tests/components/jvc_projector/test_coordinator.py b/tests/components/jvc_projector/test_coordinator.py new file mode 100644 index 00000000000..cfda3728eb0 --- /dev/null +++ b/tests/components/jvc_projector/test_coordinator.py @@ -0,0 +1,73 @@ +"""Tests for JVC Projector config entry.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.components.jvc_projector import DOMAIN +from homeassistant.components.jvc_projector.coordinator import ( + INTERVAL_FAST, + INTERVAL_SLOW, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_update( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test coordinator update runs.""" + mock_device.get_state.return_value = {"power": "standby"} + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=INTERVAL_SLOW.seconds + 1) + ) + await hass.async_block_till_done() + assert mock_device.get_state.call_count == 3 + coordinator = hass.data[DOMAIN][mock_integration.entry_id] + assert coordinator.update_interval == INTERVAL_SLOW + + +async def test_coordinator_connect_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator connect error.""" + mock_device.get_state.side_effect = JvcProjectorConnectError + 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_coordinator_auth_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator auth error.""" + mock_device.get_state.side_effect = JvcProjectorAuthError + 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_ERROR + + +async def test_coordinator_device_on( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator changes update interval when device is on.""" + mock_device.get_state.return_value = {"power": "on"} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + coordinator = hass.data[DOMAIN][mock_config_entry.entry_id] + assert coordinator.update_interval == INTERVAL_FAST diff --git a/tests/components/jvc_projector/test_init.py b/tests/components/jvc_projector/test_init.py new file mode 100644 index 00000000000..0f1ef8b6dcf --- /dev/null +++ b/tests/components/jvc_projector/test_init.py @@ -0,0 +1,71 @@ +"""Tests for JVC Projector config entry.""" + +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MOCK_MAC + +from tests.common import MockConfigEntry + + +async def test_init( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test initialization.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_MAC)}) + assert device is not None + assert device.identifiers == {(DOMAIN, MOCK_MAC)} + + +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 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_config_entry_connect_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry with connect error.""" + mock_device.connect.side_effect = JvcProjectorConnectError + + 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_config_entry_auth_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry with auth error.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + 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_ERROR diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py new file mode 100644 index 00000000000..5beccd33e38 --- /dev/null +++ b/tests/components/jvc_projector/test_remote.py @@ -0,0 +1,77 @@ +"""Tests for JVC Projector remote platform.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +ENTITY_ID = "remote.jvc_projector" + + +async def test_entity_state( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Tests entity state is registered.""" + entity = hass.states.get(ENTITY_ID) + assert entity + assert er.async_get(hass).async_get(entity.entity_id) + + +async def test_commands( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Test service call are called.""" + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert mock_device.power_on.call_count == 1 + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert mock_device.power_off.call_count == 1 + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["ok"]}, + blocking=True, + ) + assert mock_device.remote.call_count == 1 + + +async def test_unknown_command( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Test unknown service call errors.""" + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["bad"]}, + blocking=True, + ) + assert str(err.value) == "bad is not a known command"