1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 08:26:41 +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
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."""
capabilities = self.capabilities.get(command, {})
@@ -162,7 +164,10 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
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:
"""Check if the device supports a command."""

View File

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

View File

@@ -20,6 +20,7 @@ class JvcProjectorSelectDescription(SelectEntityDescription):
"""Describes JVC Projector select entities."""
command: type[Command]
snake_case_states: bool = False
SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
@@ -49,6 +50,18 @@ SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
command=cmd.Anamorphic,
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._options_map: dict[str, str] = coordinator.get_options_map(
self.command.name
self.command.name,
snake_case=description.snake_case_states,
)
@property

View File

@@ -7,16 +7,19 @@ from dataclasses import dataclass
from jvcprojector import Command, command as cmd
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
from .util import deprecate_entity
@dataclass(frozen=True, kw_only=True)
@@ -84,12 +87,29 @@ async def async_setup_entry(
) -> None:
"""Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data
entity_registry = er.async_get(hass)
async_add_entities(
JvcProjectorSensorEntity(coordinator, description)
for description in SENSORS
if coordinator.supports(description.command)
)
entities: list[JvcProjectorSensorEntity] = []
for description in SENSORS:
if not 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):

View File

@@ -71,6 +71,15 @@
"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": {
"name": "Input",
"state": {
@@ -101,6 +110,23 @@
"mid": "[%key:common::state::medium%]",
"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": {
@@ -156,7 +182,7 @@
"hdr10": "HDR10",
"hdr10-ll": "HDR10 LL",
"hdr2": "HDR2",
"last-setting": "Last Setting",
"last-setting": "Last setting",
"pana-pq": "Pana PQ",
"user-4": "User 4",
"user-5": "User 5",
@@ -182,5 +208,15 @@
"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."""
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from jvcprojector import JvcProjectorAuthError, JvcProjectorTimeoutError
from homeassistant.components.jvc_projector.const import DOMAIN
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.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.issue_registry import IssueRegistry
from . import MOCK_MAC
@@ -87,3 +88,48 @@ async def test_config_entry_auth_error(
await hass.async_block_till_done()
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."""
from datetime import timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from jvcprojector import command as cmd
from homeassistant.components.jvc_projector.coordinator import INTERVAL_FAST
from homeassistant.components.select import (
ATTR_OPTIONS,
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.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"
HDR_PROCESSING_ENTITY_ID = "select.jvc_projector_hdr_processing"
HDR_SENSOR_ENTITY_ID = "sensor.jvc_projector_hdr"
async def test_input_select(
@@ -40,3 +45,43 @@ async def test_input_select(
blocking=True,
)
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 homeassistant.components.jvc_projector.coordinator import INTERVAL_FAST
from jvcprojector import command as cmd
from homeassistant.core import HomeAssistant
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"
HDR_ENTITY_ID = "sensor.jvc_projector_hdr"
HDR_PROCESSING_ENTITY_ID = "sensor.jvc_projector_hdr_processing"
async def test_entity_state(
@@ -34,7 +32,7 @@ async def test_enable_hdr_sensor(
mock_device,
mock_integration: MockConfigEntry,
) -> None:
"""Test enabling the HDR select (disabled by default)."""
"""Test enabling the HDR sensor (disabled by default)."""
# Test entity is disabled initially
entry = entity_registry.async_get(HDR_ENTITY_ID)
@@ -43,8 +41,6 @@ async def test_enable_hdr_sensor(
# 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()
@@ -53,16 +49,18 @@ async def test_enable_hdr_sensor(
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)
)
async def test_unsupported_sensor_not_added(
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()
# 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
assert entity_registry.async_get("sensor.jvc_projector_color_depth") is None