Add new sensors and controls to JVC Projector integration (#181471)

This commit is contained in:
kernelpanic85
2026-09-14 20:55:25 +02:00
committed by GitHub
parent 8cc699740c
commit 121963e869
10 changed files with 308 additions and 10 deletions
@@ -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(
@@ -36,6 +36,7 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
name=NAME,
model=self.device.model,
manufacturer=MANUFACTURER,
sw_version=coordinator.software_version,
)
@property
@@ -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(
@@ -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] = {}
@@ -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": {
@@ -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
@@ -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"}},
@@ -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,
})
# ---
@@ -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"
+48 -3
View File
@@ -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