diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index 81b1f34681d5..616b519ad245 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -14,9 +14,10 @@ from jvcprojector import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import NAME +from .const import DOMAIN, NAME if TYPE_CHECKING: from jvcprojector import Command @@ -32,6 +33,7 @@ CORE_COMMANDS: tuple[type[Command], ...] = ( cmd.Signal, cmd.Input, cmd.LightTime, + cmd.Version, ) TRANSLATIONS = str.maketrans({"+": "p", "%": "p", ":": "x"}) @@ -69,6 +71,19 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): self.state: dict[type[Command], str] = {} + @property + def software_version(self) -> str | None: + """Return the formatted software version, if it has been cached.""" + value = self.state.get(cmd.Version) + if not value: + return None + + try: + value = value.removesuffix("PJ").zfill(4) + return f"{int(value[:2])}.{value[2:]}" + except ValueError, IndexError: + return value + @override async def _async_update_data(self) -> dict[str, Any]: """Update state with the current value of a command.""" @@ -89,7 +104,7 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): else: raise UpdateFailed(str(last_timeout)) from last_timeout - # Clear state on signal loss + # Clear state on signal loss, but keep LightTime and Version if ( new_state.get(cmd.Signal) == cmd.Signal.NONE and self.state.get(cmd.Signal) != cmd.Signal.NONE @@ -105,8 +120,22 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): else: self.update_interval = INTERVAL_SLOW + self._update_device_registry() + return {k.name: v for k, v in self.state.items()} + def _update_device_registry(self) -> None: + """Update the device registry with the cached software version.""" + if (software_version := self.software_version) is None: + return + + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, self.unique_id), self.config_entry.entry_id + ) + if device is not None and device.sw_version != software_version: + device_registry.async_update_device(device.id, sw_version=software_version) + async def _get_device_state( self, commands: set[type[Command]] ) -> dict[type[Command], str]: @@ -143,6 +172,14 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): elif self.state.get(cmd.Signal) != cmd.Signal.NONE: new_state[cmd.Signal] = cmd.Signal.NONE + # Fetch software version once while the projector is on and use the + # cached value for device info. A timeout must not prevent setup. + if power == cmd.Power.ON and cmd.Version not in self.state: + try: + await self._update_command_state(cmd.Version, new_state) + except JvcProjectorTimeoutError: + _LOGGER.debug("Command %s timed out; will retry", cmd.Version.name) + return new_state async def _update_command_state( diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py index 6c859cc12dd7..9ddbaa8e4b06 100644 --- a/homeassistant/components/jvc_projector/entity.py +++ b/homeassistant/components/jvc_projector/entity.py @@ -36,6 +36,7 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]): name=NAME, model=self.device.model, manufacturer=MANUFACTURER, + sw_version=coordinator.software_version, ) @property diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py index 20ea9f23209a..c6844a5822cb 100644 --- a/homeassistant/components/jvc_projector/select.py +++ b/homeassistant/components/jvc_projector/select.py @@ -22,40 +22,55 @@ class JvcProjectorSelectDescription(SelectEntityDescription): SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = ( - JvcProjectorSelectDescription(key="input", command=cmd.Input), + JvcProjectorSelectDescription( + key="input", translation_key="input", command=cmd.Input + ), JvcProjectorSelectDescription( key="installation_mode", + translation_key="installation_mode", command=cmd.InstallationMode, entity_registry_enabled_default=False, ), JvcProjectorSelectDescription( key="light_power", + translation_key="light_power", command=cmd.LightPower, entity_registry_enabled_default=False, ), JvcProjectorSelectDescription( key="dynamic_control", + translation_key="dynamic_control", command=cmd.DynamicControl, entity_registry_enabled_default=False, ), JvcProjectorSelectDescription( key="clear_motion_drive", + translation_key="clear_motion_drive", command=cmd.ClearMotionDrive, entity_registry_enabled_default=False, ), + JvcProjectorSelectDescription( + key="motion_enhance", + translation_key="motion_enhance", + command=cmd.MotionEnhance, + entity_registry_enabled_default=False, + ), JvcProjectorSelectDescription( key="anamorphic", + translation_key="anamorphic", command=cmd.Anamorphic, entity_registry_enabled_default=False, ), JvcProjectorSelectDescription( key="hdr_processing", + translation_key="hdr_processing", command=cmd.HdrProcessing, entity_registry_enabled_default=False, snake_case_states=True, ), JvcProjectorSelectDescription( key="picture_mode", + translation_key="picture_mode", command=cmd.PictureMode, entity_registry_enabled_default=False, snake_case_states=True, @@ -91,7 +106,7 @@ class JvcProjectorSelectEntity(JvcProjectorEntity, SelectEntity): self.command: type[Command] = description.command self.entity_description = description - self._attr_translation_key = description.key + self._attr_translation_key = description.translation_key self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._options_map: dict[str, str] = coordinator.get_options_map( diff --git a/homeassistant/components/jvc_projector/sensor.py b/homeassistant/components/jvc_projector/sensor.py index f36c3f36de48..9be92311093c 100644 --- a/homeassistant/components/jvc_projector/sensor.py +++ b/homeassistant/components/jvc_projector/sensor.py @@ -31,11 +31,13 @@ class JvcProjectorSensorDescription(SensorEntityDescription): SENSORS: tuple[JvcProjectorSensorDescription, ...] = ( JvcProjectorSensorDescription( key="power", + translation_key="power", command=cmd.Power, device_class=SensorDeviceClass.ENUM, ), JvcProjectorSensorDescription( key="light_time", + translation_key="light_time", command=cmd.LightTime, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -43,6 +45,7 @@ SENSORS: tuple[JvcProjectorSensorDescription, ...] = ( ), JvcProjectorSensorDescription( key="color_depth", + translation_key="color_depth", command=cmd.ColorDepth, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -50,6 +53,7 @@ SENSORS: tuple[JvcProjectorSensorDescription, ...] = ( ), JvcProjectorSensorDescription( key="color_space", + translation_key="color_space", command=cmd.ColorSpace, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -57,13 +61,17 @@ SENSORS: tuple[JvcProjectorSensorDescription, ...] = ( ), JvcProjectorSensorDescription( key="hdr", + translation_key="hdr", command=cmd.Hdr, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + # Keep these entities available for existing installations while they are + # migrated to the equivalent select entities. JvcProjectorSensorDescription( key="hdr_processing", + translation_key="hdr_processing", command=cmd.HdrProcessing, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -71,11 +79,36 @@ SENSORS: tuple[JvcProjectorSensorDescription, ...] = ( ), JvcProjectorSensorDescription( key="picture_mode", + translation_key="picture_mode", command=cmd.PictureMode, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + JvcProjectorSensorDescription( + key="resolution", + translation_key="resolution", + command=cmd.Source, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + JvcProjectorSensorDescription( + key="colorimetry", + translation_key="colorimetry", + command=cmd.Colorimetry, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + JvcProjectorSensorDescription( + key="link_rate", + translation_key="link_rate", + command=cmd.LinkRate, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ) @@ -124,7 +157,7 @@ class JvcProjectorSensorEntity(JvcProjectorEntity, SensorEntity): self.command: type[Command] = description.command self.entity_description = description - self._attr_translation_key = description.key + self._attr_translation_key = description.translation_key self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._options_map: dict[str, str] = {} diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index 9a217d3b834e..83a9fdc1a385 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -109,6 +109,14 @@ "normal": "[%key:common::state::normal%]" } }, + "motion_enhance": { + "name": "Motion Enhance", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "off": "[%key:common::state::off%]" + } + }, "picture_mode": { "name": "Picture Mode", "state": { @@ -147,6 +155,25 @@ "yuv": "YUV" } }, + "colorimetry": { + "name": "Colorimetry", + "state": { + "adobe-rgb": "Adobe RGB", + "adobe-ycc-601": "Adobe YCC 601", + "bt-2020-constant-luminance": "BT.2020 Constant Luminance", + "bt-2020-non-constant-luminance": "BT.2020 Non-Constant Luminance", + "bt-601": "BT.601", + "bt-709": "BT.709", + "dci-p3-d65": "DCI-P3 D65", + "dci-p3-theater": "DCI-P3 Theater", + "no-data": "No Data", + "other": "Other", + "srgb": "sRGB", + "sycc-601": "sYCC 601", + "xvycc-601": "xvYCC 601", + "xvycc-709": "xvYCC 709" + } + }, "hdr": { "name": "HDR", "state": { @@ -170,6 +197,17 @@ "light_time": { "name": "Light Time" }, + "link_rate": { + "name": "Link Rate", + "state": { + "3-gbps-3-lanes": "3 Gbps 3 Lanes", + "6-gbps-3-lanes": "6 Gbps 3 Lanes", + "6-gbps-4-lanes": "6 Gbps 4 Lanes", + "8-gbps-4-lanes": "8 Gbps 4 Lanes", + "10-gbps-4-lanes": "10 Gbps 4 Lanes", + "disable": "Disabled" + } + }, "picture_mode": { "name": "Picture Mode", "state": { @@ -196,6 +234,75 @@ "standby": "[%key:common::state::standby%]", "warming": "Warming" } + }, + "resolution": { + "name": "Resolution", + "state": { + "4k": "4K", + "4k-3840-24": "4K 3840 24 Hz", + "4k-3840-25": "4K 3840 25 Hz", + "4k-3840-30": "4K 3840 30 Hz", + "4k-3840-50": "4K 3840 50 Hz", + "4k-3840-60": "4K 3840 60 Hz", + "4k-4096-24": "4K 4096 24 Hz", + "4k-4096-25": "4K 4096 25 Hz", + "4k-4096-30": "4K 4096 30 Hz", + "4k-4096-50": "4K 4096 50 Hz", + "4k-4096-60": "4K 4096 60 Hz", + "8k-7680x4320-24": "8K 7680x4320 24 Hz", + "8k-7680x4320-25": "8K 7680x4320 25 Hz", + "8k-7680x4320-30": "8K 7680x4320 30 Hz", + "8k-7680x4320-48": "8K 7680x4320 48 Hz", + "8k-7680x4320-50": "8K 7680x4320 50 Hz", + "8k-7680x4320-60": "8K 7680x4320 60 Hz", + "480i": "480i", + "480p": "480p", + "576i": "576i", + "576p": "576p", + "720p-3d": "720p 3D", + "720p-50": "720p 50 Hz", + "720p-60": "720p 60 Hz", + "1080i-3d": "1080i 3D", + "1080i-50": "1080i 50 Hz", + "1080i-60": "1080i 60 Hz", + "1080p-100": "1080p 100 Hz", + "1080p-120": "1080p 120 Hz", + "1080p-24": "1080p 24 Hz", + "1080p-25": "1080p 25 Hz", + "1080p-30": "1080p 30 Hz", + "1080p-3d": "1080p 3D", + "1080p-50": "1080p 50 Hz", + "1080p-60": "1080p 60 Hz", + "2048x1080-p24": "2048x1080 24 Hz", + "2048x1080-p25": "2048x1080 25 Hz", + "2048x1080-p30": "2048x1080 30 Hz", + "2048x1080-p50": "2048x1080 50 Hz", + "2048x1080-p60": "2048x1080 60 Hz", + "3840x1080-p50": "3840x1080 50 Hz", + "3840x1080-p60": "3840x1080 60 Hz", + "3840x2160-100hz": "3840x2160 100 Hz", + "3840x2160-p120": "3840x2160 120 Hz", + "4096x2160-100hz": "4096x2160 100 Hz", + "4096x2160-p120": "4096x2160 120 Hz", + "fwxga-1366x768": "FWXGA 1366x768", + "no-signal": "No Signal", + "out-of-range": "Out of Range", + "qxga": "QXGA", + "svga-800x600": "SVGA 800x600", + "sxga-1280x1024": "SXGA 1280x1024", + "uxga-1600x1200": "UXGA 1600x1200", + "vga-640x480": "VGA 640x480", + "wqhd-120": "WQHD 120 Hz", + "wqhd-60": "WQHD 60 Hz", + "wqxga": "WQXGA", + "wsxgap1680x1050": "WSXGA+ 1680x1050", + "wuxga-1920x1200": "WUXGA 1920x1200", + "wxga-1280x768": "WXGA 1280x768", + "wxga-1280x800": "WXGA 1280x800", + "wxgap1440x900": "WXGA+ 1440x900", + "wxgapp1600x900": "WXGA++ 1600x900", + "xga-1024x768": "XGA 1024x768" + } } }, "switch": { diff --git a/homeassistant/components/jvc_projector/switch.py b/homeassistant/components/jvc_projector/switch.py index 144800555e5b..3acf9eefa456 100644 --- a/homeassistant/components/jvc_projector/switch.py +++ b/homeassistant/components/jvc_projector/switch.py @@ -24,11 +24,13 @@ class JvcProjectorSwitchDescription(SwitchEntityDescription): SWITCHES: Final[tuple[JvcProjectorSwitchDescription, ...]] = ( JvcProjectorSwitchDescription( key="low_latency_mode", + translation_key="low_latency_mode", command=cmd.LowLatencyMode, entity_registry_enabled_default=False, ), JvcProjectorSwitchDescription( key="eshift", + translation_key="eshift", command=cmd.EShift, entity_registry_enabled_default=False, ), @@ -63,7 +65,7 @@ class JvcProjectorSwitchEntity(JvcProjectorEntity, SwitchEntity): self.command: type[Command] = description.command self.entity_description = description - self._attr_translation_key = description.key + self._attr_translation_key = description.translation_key self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" @property diff --git a/tests/components/jvc_projector/conftest.py b/tests/components/jvc_projector/conftest.py index 18d57046607d..c8897e8c8451 100644 --- a/tests/components/jvc_projector/conftest.py +++ b/tests/components/jvc_projector/conftest.py @@ -23,6 +23,7 @@ FIXTURES: dict[str, dict[type[Command], str | type[Exception]]] = { cmd.Input: "hdmi1", cmd.Signal: "none", cmd.LightTime: "100", + cmd.Version: "0301PJ", cmd.Source: JvcProjectorTimeoutError, cmd.Hdr: JvcProjectorTimeoutError, cmd.HdrProcessing: JvcProjectorTimeoutError, @@ -35,7 +36,10 @@ FIXTURES: dict[str, dict[type[Command], str | type[Exception]]] = { cmd.Input: "hdmi1", cmd.Signal: "signal", cmd.LightTime: "100", + cmd.Version: "0301PJ", cmd.Source: "4k", + cmd.Colorimetry: "bt-709", + cmd.LinkRate: "6-gbps-4-lanes", cmd.Hdr: "hdr", cmd.HdrProcessing: "static", cmd.EShift: "on", @@ -59,6 +63,14 @@ CAPABILITIES = { "name": cmd.Source.name, "parameter": {"read": {"0": "4k"}}, }, + cmd.Colorimetry.name: { + "name": cmd.Colorimetry.name, + "parameter": {"read": {"0": "no-data", "2": "bt-709"}}, + }, + cmd.LinkRate.name: { + "name": cmd.LinkRate.name, + "parameter": {"read": {"0": "disable", "4": "6-gbps-4-lanes"}}, + }, cmd.Hdr.name: { "name": cmd.Hdr.name, "parameter": {"read": {"0": "sdr", "1": "hdr"}}, diff --git a/tests/components/jvc_projector/snapshots/test_init.ambr b/tests/components/jvc_projector/snapshots/test_init.ambr index c907e06a8a72..6c7bf83c4370 100644 --- a/tests/components/jvc_projector/snapshots/test_init.ambr +++ b/tests/components/jvc_projector/snapshots/test_init.ambr @@ -29,7 +29,7 @@ 'name': 'JVC Projector', 'name_by_user': None, 'serial_number': None, - 'sw_version': None, + 'sw_version': '3.01', 'via_device_id': None, }) # --- diff --git a/tests/components/jvc_projector/test_coordinator.py b/tests/components/jvc_projector/test_coordinator.py index efe069c2cf31..76f734f74553 100644 --- a/tests/components/jvc_projector/test_coordinator.py +++ b/tests/components/jvc_projector/test_coordinator.py @@ -10,6 +10,7 @@ from jvcprojector import ( ) import pytest +from homeassistant.components.jvc_projector.const import DOMAIN from homeassistant.components.jvc_projector.coordinator import ( INTERVAL_FAST, INTERVAL_SLOW, @@ -17,8 +18,12 @@ from homeassistant.components.jvc_projector.coordinator import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac from homeassistant.util.dt import utcnow +from . import MOCK_MAC + from tests.common import MockConfigEntry, async_fire_time_changed @@ -102,3 +107,44 @@ async def test_coordinator_command_error_keeps_other_entities_available( light_time = hass.states.get("sensor.jvc_projector_light_time") assert light_time is not None assert light_time.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "mock_device", + [{"fixture_override": {cmd.Version: JvcProjectorTimeoutError}}], + indirect=True, +) +async def test_coordinator_version_timeout_recovers_and_updates_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test a version timeout does not prevent setup and later recovers.""" + assert mock_integration.state is ConfigEntryState.LOADED + + initial_get = mock_device.get.side_effect + + async def recover_version(command) -> str: + if command is cmd.Version: + return "0301PJ" + return await initial_get(command) + + mock_device.get.side_effect = recover_version + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, format_mac(MOCK_MAC)), mock_integration.entry_id + ) + assert device is not None + assert device.sw_version is None + + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=INTERVAL_FAST.seconds + 1) + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, format_mac(MOCK_MAC)), mock_integration.entry_id + ) + assert device is not None + assert device.sw_version == "3.01" diff --git a/tests/components/jvc_projector/test_sensor.py b/tests/components/jvc_projector/test_sensor.py index cbfa94440a60..5fe304198297 100644 --- a/tests/components/jvc_projector/test_sensor.py +++ b/tests/components/jvc_projector/test_sensor.py @@ -2,7 +2,8 @@ from unittest.mock import MagicMock -from jvcprojector import command as cmd +from jvcprojector import Command, command as cmd +import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -13,6 +14,38 @@ POWER_ID = "sensor.jvc_projector_status" HDR_ENTITY_ID = "sensor.jvc_projector_hdr" +@pytest.mark.parametrize( + ("entity_id", "expected_state"), + [ + ("sensor.jvc_projector_resolution", "4k"), + ("sensor.jvc_projector_colorimetry", "bt-709"), + ("sensor.jvc_projector_link_rate", "6-gbps-4-lanes"), + ], +) +async def test_diagnostic_sensor_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_device: MagicMock, + mock_integration: MockConfigEntry, + entity_id: str, + expected_state: str, +) -> None: + """Test diagnostic sensor state and disabled-by-default behavior.""" + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(mock_integration.entry_id) + await hass.async_block_till_done() + await mock_integration.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + async def test_entity_state( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -50,17 +83,29 @@ async def test_enable_hdr_sensor( assert state is not None +@pytest.mark.parametrize( + ("unsupported_command", "entity_id"), + [ + (cmd.Source, "sensor.jvc_projector_resolution"), + (cmd.Colorimetry, "sensor.jvc_projector_colorimetry"), + (cmd.LinkRate, "sensor.jvc_projector_link_rate"), + ], +) async def test_unsupported_sensor_not_added( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_device: MagicMock, mock_config_entry: MockConfigEntry, + unsupported_command: type[Command], + entity_id: str, ) -> None: """Test unsupported sensor descriptions are skipped.""" - mock_device.supports.side_effect = lambda command: command is not cmd.ColorDepth + mock_device.supports.side_effect = lambda command: ( + command is not unsupported_command + ) 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 entity_registry.async_get("sensor.jvc_projector_color_depth") is None + assert entity_registry.async_get(entity_id) is None