1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-18 07:56:03 +01:00

Move jvc_projector sensor entities to select domain (#165194)

This commit is contained in:
Steve Easley
2026-03-18 06:34:03 -04:00
committed by GitHub
parent 2b5b0e9d0f
commit 5617e8c7bc
9 changed files with 307 additions and 33 deletions

View File

@@ -151,7 +151,9 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
return value return value
def get_options_map(self, command: str) -> dict[str, str]: def get_options_map(
self, command: str, *, snake_case: bool = False
) -> dict[str, str]:
"""Get the available options for a command.""" """Get the available options for a command."""
capabilities = self.capabilities.get(command, {}) capabilities = self.capabilities.get(command, {})
@@ -162,7 +164,10 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
values = list(capabilities.get("parameter", {}).get("read", {}).values()) values = list(capabilities.get("parameter", {}).get("read", {}).values())
return {v: v.translate(TRANSLATIONS) for v in values} options = {v: v.translate(TRANSLATIONS) for v in values}
if snake_case:
return {k: v.replace("-", "_") for k, v in options.items()}
return options
def supports(self, command: type[Command]) -> bool: def supports(self, command: type[Command]) -> bool:
"""Check if the device supports a command.""" """Check if the device supports a command."""

View File

@@ -18,6 +18,9 @@
"dynamic_control": { "dynamic_control": {
"default": "mdi:lightbulb-on-outline" "default": "mdi:lightbulb-on-outline"
}, },
"hdr_processing": {
"default": "mdi:image-filter-hdr-outline"
},
"input": { "input": {
"default": "mdi:hdmi-port" "default": "mdi:hdmi-port"
}, },
@@ -26,6 +29,9 @@
}, },
"light_power": { "light_power": {
"default": "mdi:lightbulb-on-outline" "default": "mdi:lightbulb-on-outline"
},
"picture_mode": {
"default": "mdi:movie-roll"
} }
}, },
"sensor": { "sensor": {

View File

@@ -20,6 +20,7 @@ class JvcProjectorSelectDescription(SelectEntityDescription):
"""Describes JVC Projector select entities.""" """Describes JVC Projector select entities."""
command: type[Command] command: type[Command]
snake_case_states: bool = False
SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = ( SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
@@ -49,6 +50,18 @@ SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
command=cmd.Anamorphic, command=cmd.Anamorphic,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
JvcProjectorSelectDescription(
key="hdr_processing",
command=cmd.HdrProcessing,
entity_registry_enabled_default=False,
snake_case_states=True,
),
JvcProjectorSelectDescription(
key="picture_mode",
command=cmd.PictureMode,
entity_registry_enabled_default=False,
snake_case_states=True,
),
) )
@@ -84,7 +97,8 @@ class JvcProjectorSelectEntity(JvcProjectorEntity, SelectEntity):
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
self._options_map: dict[str, str] = coordinator.get_options_map( self._options_map: dict[str, str] = coordinator.get_options_map(
self.command.name self.command.name,
snake_case=description.snake_case_states,
) )
@property @property

View File

@@ -7,16 +7,19 @@ from dataclasses import dataclass
from jvcprojector import Command, command as cmd from jvcprojector import Command, command as cmd
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
) )
from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity from .entity import JvcProjectorEntity
from .util import deprecate_entity
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@@ -84,12 +87,29 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the JVC Projector platform from a config entry.""" """Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
entity_registry = er.async_get(hass)
async_add_entities( entities: list[JvcProjectorSensorEntity] = []
JvcProjectorSensorEntity(coordinator, description) for description in SENSORS:
for description in SENSORS if not coordinator.supports(description.command):
if coordinator.supports(description.command) continue
) if description.key in (
"hdr_processing",
"picture_mode",
) and not deprecate_entity(
hass,
entity_registry,
SENSOR_DOMAIN,
f"{coordinator.unique_id}_{description.key}",
f"deprecated_sensor_{entry.entry_id}_{description.key}",
"deprecated_sensor",
f"{coordinator.unique_id}_{description.key}",
f"select.jvc_projector_{description.key}",
):
continue
entities.append(JvcProjectorSensorEntity(coordinator, description))
async_add_entities(entities)
class JvcProjectorSensorEntity(JvcProjectorEntity, SensorEntity): class JvcProjectorSensorEntity(JvcProjectorEntity, SensorEntity):

View File

@@ -71,6 +71,15 @@
"off": "[%key:common::state::off%]" "off": "[%key:common::state::off%]"
} }
}, },
"hdr_processing": {
"name": "HDR Processing",
"state": {
"frame_by_frame": "Frame-by-Frame",
"hdr10p": "HDR10+",
"scene_by_scene": "Scene-by-Scene",
"static": "Static"
}
},
"input": { "input": {
"name": "Input", "name": "Input",
"state": { "state": {
@@ -101,6 +110,23 @@
"mid": "[%key:common::state::medium%]", "mid": "[%key:common::state::medium%]",
"normal": "[%key:common::state::normal%]" "normal": "[%key:common::state::normal%]"
} }
},
"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"
}
} }
}, },
"sensor": { "sensor": {
@@ -156,7 +182,7 @@
"hdr10": "HDR10", "hdr10": "HDR10",
"hdr10-ll": "HDR10 LL", "hdr10-ll": "HDR10 LL",
"hdr2": "HDR2", "hdr2": "HDR2",
"last-setting": "Last Setting", "last-setting": "Last setting",
"pana-pq": "Pana PQ", "pana-pq": "Pana PQ",
"user-4": "User 4", "user-4": "User 4",
"user-5": "User 5", "user-5": "User 5",
@@ -182,5 +208,15 @@
"name": "Low latency mode" "name": "Low latency mode"
} }
} }
},
"issues": {
"deprecated_sensor": {
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nUpdate your dashboards, templates, automations and scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
"title": "Deprecated sensor detected"
},
"deprecated_sensor_scripts": {
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nUpdate the above automations or scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
"title": "[%key:component::jvc_projector::issues::deprecated_sensor::title%]"
}
} }
} }

View File

@@ -0,0 +1,104 @@
"""Utility helpers for the jvc_projector integration."""
from __future__ import annotations
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .const import DOMAIN
def deprecate_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
platform_domain: str,
entity_unique_id: str,
issue_id: str,
issue_string: str,
replacement_entity_unique_id: str,
replacement_entity_id: str,
version: str = "2026.9.0",
) -> bool:
"""Create an issue for deprecated entities."""
if entity_id := entity_registry.async_get_entity_id(
platform_domain, DOMAIN, entity_unique_id
):
entity_entry = entity_registry.async_get(entity_id)
if not entity_entry:
async_delete_issue(hass, DOMAIN, issue_id)
return False
items = get_automations_and_scripts_using_entity(hass, entity_id)
if entity_entry.disabled and not items:
entity_registry.async_remove(entity_id)
async_delete_issue(hass, DOMAIN, issue_id)
return False
translation_key = issue_string
placeholders = {
"entity_id": entity_id,
"entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
"replacement_entity_id": (
entity_registry.async_get_entity_id(
Platform.SELECT, DOMAIN, replacement_entity_unique_id
)
or replacement_entity_id
),
}
if items:
translation_key = f"{translation_key}_scripts"
placeholders["items"] = "\n".join(items)
async_create_issue(
hass,
DOMAIN,
issue_id,
breaks_in_ha_version=version,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=placeholders,
)
return True
async_delete_issue(hass, DOMAIN, issue_id)
return False
def get_automations_and_scripts_using_entity(
hass: HomeAssistant,
entity_id: str,
) -> list[str]:
"""Get automations and scripts using an entity."""
# These helpers return referencing automation/script entity IDs.
automations = automations_with_entity(hass, entity_id)
scripts = scripts_with_entity(hass, entity_id)
if not automations and not scripts:
return []
entity_registry = er.async_get(hass)
items: list[str] = []
for integration, entities in (
("automation", automations),
("script", scripts),
):
for used_entity_id in entities:
# Prefer entity-registry metadata so we can render edit links.
if item := entity_registry.async_get(used_entity_id):
items.append(
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
)
else:
# Keep unresolved references as plain text so they still count as usage.
items.append(f"- `{used_entity_id}`")
return items

View File

@@ -1,15 +1,16 @@
"""Tests for JVC Projector config entry.""" """Tests for JVC Projector config entry."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
from jvcprojector import JvcProjectorAuthError, JvcProjectorTimeoutError from jvcprojector import JvcProjectorAuthError, JvcProjectorTimeoutError
from homeassistant.components.jvc_projector.const import DOMAIN from homeassistant.components.jvc_projector.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.issue_registry import IssueRegistry
from . import MOCK_MAC from . import MOCK_MAC
@@ -87,3 +88,48 @@ async def test_config_entry_auth_error(
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_deprecated_sensor_issue_lifecycle(
hass: HomeAssistant,
issue_registry: IssueRegistry,
entity_registry: er.EntityRegistry,
mock_integration: MockConfigEntry,
) -> None:
"""Test deprecated sensor cleanup and issue lifecycle."""
sensor_unique_id = f"{format_mac(MOCK_MAC)}_hdr_processing"
issue_id = f"deprecated_sensor_{mock_integration.entry_id}_hdr_processing"
assert (
entity_registry.async_get_entity_id(Platform.SENSOR, DOMAIN, sensor_unique_id)
is None
)
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
sensor_entry = entity_registry.async_get_or_create(
Platform.SENSOR,
DOMAIN,
sensor_unique_id,
config_entry=mock_integration,
suggested_object_id="jvc_projector_hdr_processing",
disabled_by=er.RegistryEntryDisabler.INTEGRATION,
)
entity_id = sensor_entry.entity_id
with patch(
"homeassistant.components.jvc_projector.util.get_automations_and_scripts_using_entity",
return_value=["- [Test Automation](/config/automation/edit/test_automation)"],
):
await hass.config_entries.async_reload(mock_integration.entry_id)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.translation_key == "deprecated_sensor_scripts"
assert entity_registry.async_get(entity_id) is not None
await hass.config_entries.async_reload(mock_integration.entry_id)
await hass.async_block_till_done()
assert entity_registry.async_get(entity_id) is None
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None

View File

@@ -1,9 +1,12 @@
"""Tests for JVC Projector select platform.""" """Tests for JVC Projector select platform."""
from datetime import timedelta
from unittest.mock import MagicMock from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from jvcprojector import command as cmd from jvcprojector import command as cmd
from homeassistant.components.jvc_projector.coordinator import INTERVAL_FAST
from homeassistant.components.select import ( from homeassistant.components.select import (
ATTR_OPTIONS, ATTR_OPTIONS,
DOMAIN as SELECT_DOMAIN, DOMAIN as SELECT_DOMAIN,
@@ -13,9 +16,11 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_OPTION
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
INPUT_ENTITY_ID = "select.jvc_projector_input" INPUT_ENTITY_ID = "select.jvc_projector_input"
HDR_PROCESSING_ENTITY_ID = "select.jvc_projector_hdr_processing"
HDR_SENSOR_ENTITY_ID = "sensor.jvc_projector_hdr"
async def test_input_select( async def test_input_select(
@@ -40,3 +45,43 @@ async def test_input_select(
blocking=True, blocking=True,
) )
mock_device.set.assert_called_once_with(cmd.Input, cmd.Input.HDMI2) mock_device.set.assert_called_once_with(cmd.Input, cmd.Input.HDMI2)
async def test_enable_hdr_processing_select(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_device: MagicMock,
mock_integration: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test enabling the HDR processing select (disabled by default)."""
entry = entity_registry.async_get(HDR_PROCESSING_ENTITY_ID)
assert entry is not None
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
hdr_sensor_entry = entity_registry.async_get(HDR_SENSOR_ENTITY_ID)
assert hdr_sensor_entry is not None
assert hdr_sensor_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
entity_registry.async_update_entity(HDR_SENSOR_ENTITY_ID, disabled_by=None)
entity_registry.async_update_entity(HDR_PROCESSING_ENTITY_ID, disabled_by=None)
await hass.config_entries.async_reload(mock_integration.entry_id)
await hass.async_block_till_done()
state = hass.states.get(HDR_PROCESSING_ENTITY_ID)
assert state is not None
assert state.state == "unknown"
freezer.tick(timedelta(seconds=INTERVAL_FAST.seconds + 1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(HDR_PROCESSING_ENTITY_ID)
assert state is not None
assert state.state == "static"
freezer.tick(timedelta(seconds=INTERVAL_FAST.seconds + 1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(HDR_PROCESSING_ENTITY_ID)
assert state is not None
assert state.state == "static"

View File

@@ -1,18 +1,16 @@
"""Tests for the JVC Projector binary sensor device.""" """Tests for JVC Projector sensor platform."""
from datetime import timedelta
from unittest.mock import MagicMock from unittest.mock import MagicMock
from homeassistant.components.jvc_projector.coordinator import INTERVAL_FAST from jvcprojector import command as cmd
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.util.dt import utcnow
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry
POWER_ID = "sensor.jvc_projector_status" POWER_ID = "sensor.jvc_projector_status"
HDR_ENTITY_ID = "sensor.jvc_projector_hdr" HDR_ENTITY_ID = "sensor.jvc_projector_hdr"
HDR_PROCESSING_ENTITY_ID = "sensor.jvc_projector_hdr_processing"
async def test_entity_state( async def test_entity_state(
@@ -34,7 +32,7 @@ async def test_enable_hdr_sensor(
mock_device, mock_device,
mock_integration: MockConfigEntry, mock_integration: MockConfigEntry,
) -> None: ) -> None:
"""Test enabling the HDR select (disabled by default).""" """Test enabling the HDR sensor (disabled by default)."""
# Test entity is disabled initially # Test entity is disabled initially
entry = entity_registry.async_get(HDR_ENTITY_ID) entry = entity_registry.async_get(HDR_ENTITY_ID)
@@ -43,8 +41,6 @@ async def test_enable_hdr_sensor(
# Enable entity # Enable entity
entity_registry.async_update_entity(HDR_ENTITY_ID, disabled_by=None) 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 # Add to hass
await hass.config_entries.async_reload(mock_integration.entry_id) await hass.config_entries.async_reload(mock_integration.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -53,16 +49,18 @@ async def test_enable_hdr_sensor(
state = hass.states.get(HDR_ENTITY_ID) state = hass.states.get(HDR_ENTITY_ID)
assert state is not None assert state is not None
# Allow deferred updates to run
async_fire_time_changed( async def test_unsupported_sensor_not_added(
hass, utcnow() + timedelta(seconds=INTERVAL_FAST.seconds + 1) hass: HomeAssistant,
) entity_registry: er.EntityRegistry,
mock_device: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test unsupported sensor descriptions are skipped."""
mock_device.supports.side_effect = lambda command: command is not cmd.ColorDepth
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
# Allow deferred updates to run again assert entity_registry.async_get("sensor.jvc_projector_color_depth") is None
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