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
This commit is contained in:
Steve Easley 2023-05-05 14:44:53 -04:00 committed by GitHub
parent bcbc8539a6
commit 6bbcf2f689
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1030 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
"""Constants for the jvc_projector integration."""
NAME = "JVC Projector"
DOMAIN = "jvc_projector"
MANUFACTURER = "JVC"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -214,6 +214,7 @@ FLOWS = {
"jellyfin",
"juicenet",
"justnimbus",
"jvc_projector",
"kaleidescape",
"keenetic_ndms2",
"kegtron",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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