Add additional JVC Projector entities (#161134)

This commit is contained in:
Steve Easley
2026-01-29 06:45:19 -05:00
committed by GitHub
parent 95014d7e6d
commit 3551382f8d
15 changed files with 559 additions and 142 deletions

View File

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

View File

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

View File

@@ -3,7 +3,3 @@
NAME = "JVC Projector"
DOMAIN = "jvc_projector"
MANUFACTURER = "JVC"
POWER = "power"
INPUT = "input"
SOURCE = "source"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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