diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py index 547b0a67761..a1bbef674d0 100644 --- a/homeassistant/components/jvc_projector/__init__.py +++ b/homeassistant/components/jvc_projector/__init__.py @@ -76,7 +76,7 @@ async def async_migrate_entities( def _update_entry(entry: RegistryEntry) -> dict[str, str] | None: """Fix unique_id of power binary_sensor entry.""" if entry.domain == Platform.BINARY_SENSOR and ":" not in entry.unique_id: - if "_power" in entry.unique_id: + if entry.unique_id.endswith("_power"): return {"new_unique_id": f"{coordinator.unique_id}_power"} return None diff --git a/homeassistant/components/jvc_projector/binary_sensor.py b/homeassistant/components/jvc_projector/binary_sensor.py index 86e3e104f32..55c8ab765c3 100644 --- a/homeassistant/components/jvc_projector/binary_sensor.py +++ b/homeassistant/components/jvc_projector/binary_sensor.py @@ -8,7 +8,6 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import POWER from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity @@ -41,4 +40,4 @@ class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the JVC Projector is on.""" - return self.coordinator.data[POWER] in ON_STATUS + return self.coordinator.data[cmd.Power.name] in ON_STATUS diff --git a/homeassistant/components/jvc_projector/const.py b/homeassistant/components/jvc_projector/const.py index d0dbd1f73f8..e15aa93bfa5 100644 --- a/homeassistant/components/jvc_projector/const.py +++ b/homeassistant/components/jvc_projector/const.py @@ -3,7 +3,3 @@ NAME = "JVC Projector" DOMAIN = "jvc_projector" MANUFACTURER = "JVC" - -POWER = "power" -INPUT = "input" -SOURCE = "source" diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index 58ca14a3738..ccd125b98ed 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -2,29 +2,40 @@ from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import TYPE_CHECKING, Any -from jvcprojector import ( - JvcProjector, - JvcProjectorAuthError, - JvcProjectorTimeoutError, - command as cmd, -) +from jvcprojector import JvcProjector, JvcProjectorTimeoutError, command as cmd from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import INPUT, NAME, POWER +from .const import NAME + +if TYPE_CHECKING: + from jvcprojector import Command + _LOGGER = logging.getLogger(__name__) INTERVAL_SLOW = timedelta(seconds=10) INTERVAL_FAST = timedelta(seconds=5) +CORE_COMMANDS: tuple[type[Command], ...] = ( + cmd.Power, + cmd.Signal, + cmd.Input, + cmd.LightTime, +) + +TRANSLATIONS = str.maketrans({"+": "p", "%": "p", ":": "x"}) + +TIMEOUT_RETRIES = 12 +TIMEOUT_SLEEP = 1 + type JVCConfigEntry = ConfigEntry[JvcProjectorDataUpdateCoordinator] @@ -51,27 +62,108 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): assert config_entry.unique_id is not None self.unique_id = config_entry.unique_id + self.capabilities = self.device.capabilities() + + self.state: dict[type[Command], str] = {} + async def _async_update_data(self) -> dict[str, Any]: - """Get the latest state data.""" - state: dict[str, str | None] = { - POWER: None, - INPUT: None, - } + """Update state with the current value of a command.""" + commands: set[type[Command]] = set(self.async_contexts()) + commands = commands.difference(CORE_COMMANDS) - try: - state[POWER] = await self.device.get(cmd.Power) + last_timeout: JvcProjectorTimeoutError | None = None - if state[POWER] == cmd.Power.ON: - state[INPUT] = await self.device.get(cmd.Input) + for _ in range(TIMEOUT_RETRIES): + try: + new_state = await self._get_device_state(commands) + break + except JvcProjectorTimeoutError as err: + # Timeouts are expected when the projector loses signal and ignores commands for a brief time. + last_timeout = err + await asyncio.sleep(TIMEOUT_SLEEP) + else: + raise UpdateFailed(str(last_timeout)) from last_timeout - except JvcProjectorTimeoutError 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 + # Clear state on signal loss + if ( + new_state.get(cmd.Signal) == cmd.Signal.NONE + and self.state.get(cmd.Signal) != cmd.Signal.NONE + ): + self.state = {k: v for k, v in self.state.items() if k in CORE_COMMANDS} - if state[POWER] != cmd.Power.STANDBY: + # Update state with new values + for k, v in new_state.items(): + self.state[k] = v + + if self.state[cmd.Power] != cmd.Power.STANDBY: self.update_interval = INTERVAL_FAST else: self.update_interval = INTERVAL_SLOW - return state + return {k.name: v for k, v in self.state.items()} + + async def _get_device_state( + self, commands: set[type[Command]] + ) -> dict[type[Command], str]: + """Get the current state of the device.""" + new_state: dict[type[Command], str] = {} + deferred_commands: list[type[Command]] = [] + + power = await self._update_command_state(cmd.Power, new_state) + + if power == cmd.Power.ON: + signal = await self._update_command_state(cmd.Signal, new_state) + await self._update_command_state(cmd.Input, new_state) + await self._update_command_state(cmd.LightTime, new_state) + + if signal == cmd.Signal.SIGNAL: + for command in commands: + if command.depends: + # Command has dependencies so defer until below + deferred_commands.append(command) + else: + await self._update_command_state(command, new_state) + + # Deferred commands should have had dependencies met above + for command in deferred_commands: + depend_command, depend_values = next(iter(command.depends.items())) + value: str | None = None + if depend_command in new_state: + value = new_state[depend_command] + elif depend_command in self.state: + value = self.state[depend_command] + if value and value in depend_values: + await self._update_command_state(command, new_state) + + elif self.state.get(cmd.Signal) != cmd.Signal.NONE: + new_state[cmd.Signal] = cmd.Signal.NONE + + return new_state + + async def _update_command_state( + self, command: type[Command], new_state: dict[type[Command], str] + ) -> str | None: + """Update state with the current value of a command.""" + value = await self.device.get(command) + + if value != self.state.get(command): + new_state[command] = value + + return value + + def get_options_map(self, command: str) -> dict[str, str]: + """Get the available options for a command.""" + capabilities = self.capabilities.get(command, {}) + + if TYPE_CHECKING: + assert isinstance(capabilities, dict) + assert isinstance(capabilities.get("parameter", {}), dict) + assert isinstance(capabilities.get("parameter", {}).get("read", {}), dict) + + values = list(capabilities.get("parameter", {}).get("read", {}).values()) + + return {v: v.translate(TRANSLATIONS) for v in values} + + def supports(self, command: type[Command]) -> bool: + """Check if the device supports a command.""" + return self.device.supports(command) diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py index 317bc5ce654..4bb084dc7f9 100644 --- a/homeassistant/components/jvc_projector/entity.py +++ b/homeassistant/components/jvc_projector/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from jvcprojector import JvcProjector +from jvcprojector import Command, JvcProjector from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -20,9 +20,13 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: JvcProjectorDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: JvcProjectorDataUpdateCoordinator, + command: type[Command] | None = None, + ) -> None: """Initialize the entity.""" - super().__init__(coordinator) + super().__init__(coordinator, command) self._attr_unique_id = coordinator.unique_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/jvc_projector/icons.json b/homeassistant/components/jvc_projector/icons.json index 0ac1db4fb34..803267c221f 100644 --- a/homeassistant/components/jvc_projector/icons.json +++ b/homeassistant/components/jvc_projector/icons.json @@ -1,7 +1,7 @@ { "entity": { "binary_sensor": { - "jvc_power": { + "power": { "default": "mdi:projector-off", "state": { "on": "mdi:projector" @@ -9,17 +9,47 @@ } }, "select": { + "anamorphic": { + "default": "mdi:fit-to-screen-outline" + }, + "clear_motion_drive": { + "default": "mdi:blur" + }, + "dynamic_control": { + "default": "mdi:lightbulb-on-outline" + }, "input": { "default": "mdi:hdmi-port" + }, + "installation_mode": { + "default": "mdi:aspect-ratio" + }, + "light_power": { + "default": "mdi:lightbulb-on-outline" } }, "sensor": { - "jvc_power_status": { - "default": "mdi:power-plug-off", + "color_depth": { + "default": "mdi:palette-outline" + }, + "color_space": { + "default": "mdi:palette-outline" + }, + "hdr": { + "default": "mdi:image-filter-hdr-outline" + }, + "hdr_processing": { + "default": "mdi:image-filter-hdr-outline" + }, + "picture_mode": { + "default": "mdi:movie-roll" + }, + "power": { + "default": "mdi:power", "state": { "cooling": "mdi:snowflake", "error": "mdi:alert-circle", - "on": "mdi:power-plug", + "on": "mdi:power", "warming": "mdi:heat-wave" } } diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index f2e436f41d0..07a8d1c835b 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -14,7 +14,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import POWER from .coordinator import JVCConfigEntry from .entity import JvcProjectorEntity @@ -65,6 +64,8 @@ RENAMED_COMMANDS: dict[str, str] = { "hdmi2": cmd.Remote.HDMI2, } +ON_STATUS = (cmd.Power.ON, cmd.Power.WARMING) + _LOGGER = logging.getLogger(__name__) @@ -86,7 +87,7 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): @property def is_on(self) -> bool: """Return True if the entity is on.""" - return self.coordinator.data[POWER] in (cmd.Power.ON, cmd.Power.WARMING) + return self.coordinator.data.get(cmd.Power.name) in ON_STATUS async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py index 861c2846a0a..717cd06e4b5 100644 --- a/homeassistant/components/jvc_projector/select.py +++ b/homeassistant/components/jvc_projector/select.py @@ -2,11 +2,10 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Final -from jvcprojector import JvcProjector, command as cmd +from jvcprojector import Command, command as cmd from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant @@ -20,17 +19,37 @@ from .entity import JvcProjectorEntity class JvcProjectorSelectDescription(SelectEntityDescription): """Describes JVC Projector select entities.""" - command: Callable[[JvcProjector, str], Awaitable[None]] + command: type[Command] -SELECTS: Final[list[JvcProjectorSelectDescription]] = [ +SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = ( + JvcProjectorSelectDescription(key="input", command=cmd.Input), JvcProjectorSelectDescription( - key="input", - translation_key="input", - options=[cmd.Input.HDMI1, cmd.Input.HDMI2], - command=lambda device, option: device.set(cmd.Input, option), - ) -] + key="installation_mode", + command=cmd.InstallationMode, + entity_registry_enabled_default=False, + ), + JvcProjectorSelectDescription( + key="light_power", + command=cmd.LightPower, + entity_registry_enabled_default=False, + ), + JvcProjectorSelectDescription( + key="dynamic_control", + command=cmd.DynamicControl, + entity_registry_enabled_default=False, + ), + JvcProjectorSelectDescription( + key="clear_motion_drive", + command=cmd.ClearMotionDrive, + entity_registry_enabled_default=False, + ), + JvcProjectorSelectDescription( + key="anamorphic", + command=cmd.Anamorphic, + entity_registry_enabled_default=False, + ), +) async def async_setup_entry( @@ -42,30 +61,45 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - JvcProjectorSelectEntity(coordinator, description) for description in SELECTS + JvcProjectorSelectEntity(coordinator, description) + for description in SELECTS + if coordinator.supports(description.command) ) class JvcProjectorSelectEntity(JvcProjectorEntity, SelectEntity): """Representation of a JVC Projector select entity.""" - entity_description: JvcProjectorSelectDescription - def __init__( self, coordinator: JvcProjectorDataUpdateCoordinator, description: JvcProjectorSelectDescription, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) + super().__init__(coordinator, description.command) + self.command: type[Command] = description.command + self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self._attr_translation_key = description.key + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + + self._options_map: dict[str, str] = coordinator.get_options_map( + self.command.name + ) + + @property + def options(self) -> list[str]: + """Return a list of selectable options.""" + return list(self._options_map.values()) @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - return self.coordinator.data[self.entity_description.key] + if value := self.coordinator.data.get(self.command.name): + return self._options_map.get(value) + return None async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self.entity_description.command(self.coordinator.device, option) + value = next((k for k, v in self._options_map.items() if v == option), None) + await self.coordinator.device.set(self.command, value) diff --git a/homeassistant/components/jvc_projector/sensor.py b/homeassistant/components/jvc_projector/sensor.py index dd0c16e6fff..626343de01f 100644 --- a/homeassistant/components/jvc_projector/sensor.py +++ b/homeassistant/components/jvc_projector/sensor.py @@ -2,33 +2,77 @@ from __future__ import annotations -from jvcprojector import command as cmd +from dataclasses import dataclass + +from jvcprojector import Command, command as cmd from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity -JVC_SENSORS = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class JvcProjectorSensorDescription(SensorEntityDescription): + """Describes JVC Projector sensor entities.""" + + command: type[Command] + + +SENSORS: tuple[JvcProjectorSensorDescription, ...] = ( + JvcProjectorSensorDescription( key="power", - translation_key="jvc_power_status", + command=cmd.Power, + device_class=SensorDeviceClass.ENUM, + ), + JvcProjectorSensorDescription( + key="light_time", + command=cmd.LightTime, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + ), + JvcProjectorSensorDescription( + key="color_depth", + command=cmd.ColorDepth, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=[ - cmd.Power.STANDBY, - cmd.Power.ON, - cmd.Power.WARMING, - cmd.Power.COOLING, - cmd.Power.ERROR, - ], + entity_registry_enabled_default=False, + ), + JvcProjectorSensorDescription( + key="color_space", + command=cmd.ColorSpace, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + JvcProjectorSensorDescription( + key="hdr", + command=cmd.Hdr, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + JvcProjectorSensorDescription( + key="hdr_processing", + command=cmd.HdrProcessing, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + JvcProjectorSensorDescription( + key="picture_mode", + command=cmd.PictureMode, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), ) @@ -42,24 +86,48 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - JvcSensor(coordinator, description) for description in JVC_SENSORS + JvcProjectorSensorEntity(coordinator, description) + for description in SENSORS + if coordinator.supports(description.command) ) -class JvcSensor(JvcProjectorEntity, SensorEntity): +class JvcProjectorSensorEntity(JvcProjectorEntity, SensorEntity): """The entity class for JVC Projector integration.""" def __init__( self, coordinator: JvcProjectorDataUpdateCoordinator, - description: SensorEntityDescription, + description: JvcProjectorSensorDescription, ) -> None: """Initialize the JVC Projector sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, description.command) + self.command: type[Command] = description.command + self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self._attr_translation_key = description.key + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + + self._options_map: dict[str, str] = {} + if self.device_class == SensorDeviceClass.ENUM: + self._options_map = coordinator.get_options_map(self.command.name) + + @property + def options(self) -> list[str] | None: + """Return a set of possible options.""" + if self.device_class == SensorDeviceClass.ENUM: + return list(self._options_map.values()) + return None @property def native_value(self) -> str | None: """Return the native value.""" - return self.coordinator.data[self.entity_description.key] + value = self.coordinator.data.get(self.command.name) + + if value is None: + return None + + if self.device_class == SensorDeviceClass.ENUM: + return self._options_map.get(value) + + return value diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index 89c54ce5f2c..01b192e0270 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -36,20 +36,134 @@ "entity": { "binary_sensor": { "power": { - "name": "[%key:component::binary_sensor::entity_component::power::name%]" + "name": "Power" } }, "select": { + "anamorphic": { + "name": "Anamorphic", + "state": { + "a": "A", + "b": "B", + "c": "C", + "d": "D", + "off": "[%key:common::state::off%]" + } + }, + "clear_motion_drive": { + "name": "Clear Motion Drive", + "state": { + "high": "[%key:common::state::high%]", + "inverse-telecine": "Inverse Telecine", + "low": "[%key:common::state::low%]", + "off": "[%key:common::state::off%]" + } + }, + "dynamic_control": { + "name": "Dynamic Control", + "state": { + "balanced": "Balanced", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "mode-1": "Mode 1", + "mode-2": "Mode 2", + "mode-3": "Mode 3", + "off": "[%key:common::state::off%]" + } + }, "input": { "name": "Input", "state": { "hdmi1": "HDMI 1", "hdmi2": "HDMI 2" } + }, + "installation_mode": { + "name": "Installation Mode", + "state": { + "memory-1": "Memory 1", + "memory-10": "Memory 10", + "memory-2": "Memory 2", + "memory-3": "Memory 3", + "memory-4": "Memory 4", + "memory-5": "Memory 5", + "memory-6": "Memory 6", + "memory-7": "Memory 7", + "memory-8": "Memory 8", + "memory-9": "Memory 9" + } + }, + "light_power": { + "name": "Light Power", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "normal": "[%key:common::state::normal%]" + } } }, "sensor": { - "jvc_power_status": { + "color_depth": { + "name": "Color Depth", + "state": { + "8-bit": "8-bit", + "10-bit": "10-bit", + "12-bit": "12-bit" + } + }, + "color_space": { + "name": "Color Space", + "state": { + "rgb": "RGB", + "xv-color": "XV Color", + "ycbcr-420": "YCbCr 4:2:0", + "ycbcr-422": "YCbCr 4:2:2", + "ycbcr-444": "YCbCr 4:4:4", + "yuv": "YUV" + } + }, + "hdr": { + "name": "HDR", + "state": { + "hdr": "HDR", + "hdr10p": "HDR10+", + "hybrid-log": "Hybrid Log", + "none": "None", + "sdr": "SDR", + "smpte-st-2084": "SMPTE ST 2084" + } + }, + "hdr_processing": { + "name": "HDR Processing", + "state": { + "frame-by-frame": "Frame-by-Frame", + "hdr10p": "HDR10+", + "scene-by-scene": "Scene-by-Scene", + "static": "Static" + } + }, + "light_time": { + "name": "Light Time" + }, + "picture_mode": { + "name": "Picture Mode", + "state": { + "frame-adapt-hdr": "Frame Adapt HDR", + "frame-adapt-hdr2": "Frame Adapt HDR2", + "frame-adapt-hdr3": "Frame Adapt HDR3", + "hdr1": "HDR1", + "hdr10": "HDR10", + "hdr10-ll": "HDR10 LL", + "hdr2": "HDR2", + "last-setting": "Last Setting", + "pana-pq": "Pana PQ", + "user-4": "User 4", + "user-5": "User 5", + "user-6": "User 6" + } + }, + "power": { "name": "Status", "state": { "cooling": "Cooling", diff --git a/tests/components/jvc_projector/conftest.py b/tests/components/jvc_projector/conftest.py index 57603e0a055..505eeebabe2 100644 --- a/tests/components/jvc_projector/conftest.py +++ b/tests/components/jvc_projector/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch -from jvcprojector import command as cmd +from jvcprojector import Command, JvcProjectorTimeoutError, command as cmd import pytest from homeassistant.components.jvc_projector.const import DOMAIN @@ -15,6 +15,62 @@ from . import MOCK_HOST, MOCK_MAC, MOCK_MODEL, MOCK_PASSWORD, MOCK_PORT from tests.common import MockConfigEntry +FIXTURES: dict[str, dict[type[Command], str | type[Exception]]] = { + "standby": { + cmd.MacAddress: MOCK_MAC, + cmd.ModelName: MOCK_MODEL, + cmd.Power: "standby", + cmd.Input: "hdmi1", + cmd.Signal: "none", + cmd.LightTime: "100", + cmd.Source: JvcProjectorTimeoutError, + cmd.Hdr: JvcProjectorTimeoutError, + cmd.HdrProcessing: JvcProjectorTimeoutError, + }, + "on": { + cmd.MacAddress: MOCK_MAC, + cmd.ModelName: MOCK_MODEL, + cmd.Power: "on", + cmd.Input: "hdmi1", + cmd.Signal: "signal", + cmd.LightTime: "100", + cmd.Source: "4k", + cmd.Hdr: "hdr", + cmd.HdrProcessing: "static", + }, +} + +CAPABILITIES = { + cmd.Power.name: { + "name": cmd.Power.name, + "parameter": {"read": {"0": "standby", "1": "on"}}, + }, + cmd.Input.name: { + "name": cmd.Input.name, + "parameter": {"read": {"6": "hdmi1", "7": "hdmi2"}}, + }, + cmd.Signal.name: { + "name": cmd.Signal.name, + "parameter": {"read": {"0": "none", "1": "signal"}}, + }, + cmd.Source.name: { + "name": cmd.Source.name, + "parameter": {"read": {"0": "4k"}}, + }, + cmd.Hdr.name: { + "name": cmd.Hdr.name, + "parameter": {"read": {"0": "sdr", "1": "hdr"}}, + }, + cmd.HdrProcessing.name: { + "name": cmd.HdrProcessing.name, + "parameter": {"read": {"0": "hdr", "1": "static"}}, + }, + cmd.LightTime.name: { + "name": cmd.LightTime.name, + "parameter": "empty", + }, +} + @pytest.fixture(name="mock_device") def fixture_mock_device( @@ -22,31 +78,36 @@ def fixture_mock_device( ) -> Generator[MagicMock]: """Return a mocked JVC Projector device.""" target = "homeassistant.components.jvc_projector.JvcProjector" - fixture: dict[str, str] = { - "mac": MOCK_MAC, - "power": "standby", - "input": "hdmi-1", - } + fixture = FIXTURES["on"].copy() if hasattr(request, "param"): target = request.param.get("target", target) - fixture = request.param.get("get", fixture) + if "fixture" in request.param: + if isinstance(request.param["fixture"], str): + fixture = FIXTURES[request.param["fixture"]].copy() + else: + fixture = request.param["fixture"].copy() + + if "fixture_override" in request.param: + fixture.update(request.param["fixture_override"]) async def device_get(command) -> str: - if command is cmd.MacAddress: - return fixture["mac"] - if command is cmd.Power: - return fixture["power"] - if command is cmd.Input: - return fixture["input"] - raise ValueError(f"Fixture failure; unexpected command {command}") + if command in fixture: + value = fixture[command] + if isinstance(value, type) and issubclass(value, Exception): + raise value + return value + raise ValueError(f"Test fixture failure; unexpected command {command}") with patch(target, autospec=True) as mock: device = mock.return_value + device.ip = MOCK_HOST device.host = MOCK_HOST device.port = MOCK_PORT + device.mac = MOCK_MAC device.model = MOCK_MODEL device.get.side_effect = device_get + device.capabilities.return_value = CAPABILITIES yield device @@ -71,7 +132,11 @@ async def fixture_mock_integration( 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 + with ( + patch("homeassistant.components.jvc_projector.coordinator.TIMEOUT_RETRIES", 2), + patch("homeassistant.components.jvc_projector.coordinator.TIMEOUT_SLEEP", 0.1), + ): + 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_coordinator.py b/tests/components/jvc_projector/test_coordinator.py index 569e894044d..fd1eddb83d8 100644 --- a/tests/components/jvc_projector/test_coordinator.py +++ b/tests/components/jvc_projector/test_coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock -from jvcprojector import JvcProjectorAuthError, JvcProjectorTimeoutError +from jvcprojector import JvcProjectorTimeoutError, command as cmd import pytest from homeassistant.components.jvc_projector.coordinator import ( @@ -14,11 +14,14 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow -from . import MOCK_MAC - from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.parametrize( + "mock_device", + [{"fixture": "standby"}], + indirect=True, +) async def test_coordinator_update( hass: HomeAssistant, mock_device: AsyncMock, @@ -33,45 +36,25 @@ async def test_coordinator_update( assert coordinator.update_interval == INTERVAL_SLOW -async def test_coordinator_setup_connect_error( +async def test_coordinator_device_on( hass: HomeAssistant, mock_device: AsyncMock, - mock_config_entry: MockConfigEntry, + mock_integration: MockConfigEntry, ) -> None: - """Test coordinator connect error.""" - mock_device.get.side_effect = JvcProjectorTimeoutError - 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_setup_auth_error( - hass: HomeAssistant, - mock_device: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test coordinator auth error.""" - mock_device.get.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 + """Test coordinator changes update interval when device is on.""" + coordinator = mock_integration.runtime_data + assert coordinator.update_interval == INTERVAL_FAST @pytest.mark.parametrize( "mock_device", - [{"get": {"mac": MOCK_MAC, "power": "on", "input": "hdmi-1"}}], + [{"fixture_override": {cmd.Power: JvcProjectorTimeoutError}}], indirect=True, ) -async def test_coordinator_device_on( +async def test_coordinator_setup_connect_error( hass: HomeAssistant, mock_device: AsyncMock, - mock_config_entry: MockConfigEntry, + mock_integration: MockConfigEntry, ) -> None: - """Test coordinator changes update interval when device is 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 = mock_config_entry.runtime_data - assert coordinator.update_interval == INTERVAL_FAST + """Test coordinator connect error.""" + assert mock_integration.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py index 56e929d4898..0ba2f18fe1a 100644 --- a/tests/components/jvc_projector/test_remote.py +++ b/tests/components/jvc_projector/test_remote.py @@ -43,7 +43,6 @@ async def test_commands( {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) - assert mock_device.get.call_count == 3 await hass.services.async_call( REMOTE_DOMAIN, @@ -51,7 +50,6 @@ async def test_commands( {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) - assert mock_device.get.call_count == 4 await hass.services.async_call( REMOTE_DOMAIN, @@ -59,7 +57,6 @@ async def test_commands( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["ok"]}, blocking=True, ) - assert mock_device.remote.call_count == 1 await hass.services.async_call( REMOTE_DOMAIN, @@ -67,7 +64,6 @@ async def test_commands( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["hdmi1"]}, blocking=True, ) - assert mock_device.remote.call_count == 2 await hass.services.async_call( REMOTE_DOMAIN, @@ -75,7 +71,6 @@ async def test_commands( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["anamo"]}, blocking=True, ) - assert mock_device.remote.call_count == 3 await hass.services.async_call( REMOTE_DOMAIN, diff --git a/tests/components/jvc_projector/test_select.py b/tests/components/jvc_projector/test_select.py index 5e8bed6eec7..2b758e5fc6b 100644 --- a/tests/components/jvc_projector/test_select.py +++ b/tests/components/jvc_projector/test_select.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock from jvcprojector import command as cmd -import pytest from homeassistant.components.select import ( ATTR_OPTIONS, @@ -14,18 +13,11 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_OPTION from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MOCK_MAC - from tests.common import MockConfigEntry INPUT_ENTITY_ID = "select.jvc_projector_input" -@pytest.mark.parametrize( - "mock_device", - [{"get": {"mac": MOCK_MAC, "power": "on", "input": "hdmi-1"}}], - indirect=True, -) async def test_input_select( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/jvc_projector/test_sensor.py b/tests/components/jvc_projector/test_sensor.py index 87ebe737dec..8e2436f36f8 100644 --- a/tests/components/jvc_projector/test_sensor.py +++ b/tests/components/jvc_projector/test_sensor.py @@ -1,13 +1,18 @@ """Tests for the JVC Projector binary sensor device.""" +from datetime import timedelta from unittest.mock import MagicMock +from homeassistant.components.jvc_projector.coordinator import INTERVAL_FAST from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed POWER_ID = "sensor.jvc_projector_status" +HDR_ENTITY_ID = "sensor.jvc_projector_hdr" +HDR_PROCESSING_ENTITY_ID = "sensor.jvc_projector_hdr_processing" async def test_entity_state( @@ -20,5 +25,44 @@ async def test_entity_state( state = hass.states.get(POWER_ID) assert state assert entity_registry.async_get(state.entity_id) + assert state.state == "on" - assert state.state == "standby" + +async def test_enable_hdr_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_device, + mock_integration: MockConfigEntry, +) -> None: + """Test enabling the HDR select (disabled by default).""" + + # Test entity is disabled initially + entry = entity_registry.async_get(HDR_ENTITY_ID) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Enable entity + entity_registry.async_update_entity(HDR_ENTITY_ID, disabled_by=None) + entity_registry.async_update_entity(HDR_PROCESSING_ENTITY_ID, disabled_by=None) + + # Add to hass + await hass.config_entries.async_reload(mock_integration.entry_id) + await hass.async_block_till_done() + + # Verify entity is enabled + state = hass.states.get(HDR_ENTITY_ID) + assert state is not None + + # Allow deferred updates to run + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=INTERVAL_FAST.seconds + 1) + ) + await hass.async_block_till_done() + + # Allow deferred updates to run again + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=INTERVAL_FAST.seconds + 1) + ) + await hass.async_block_till_done() + + assert hass.states.get(HDR_PROCESSING_ENTITY_ID) is not None