From 181741cab6355c70d8c524a7d36d5845cb570e0a Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 22 Sep 2025 01:03:29 +0300 Subject: [PATCH] Use component role in Shelly sensor platform (#152710) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/shelly/const.py | 9 -- homeassistant/components/shelly/entity.py | 2 +- homeassistant/components/shelly/sensor.py | 112 ++++++++++++++++++---- tests/components/shelly/test_sensor.py | 71 ++++++++++++-- 4 files changed, 157 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 5a5603747bd..8732d272ffc 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -29,7 +29,6 @@ from aioshelly.const import ( ) from homeassistant.components.number import NumberMode -from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import UnitOfVolumeFlowRate DOMAIN: Final = "shelly" @@ -298,14 +297,6 @@ API_WS_URL = "/api/shelly/ws" COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") -ROLE_TO_DEVICE_CLASS_MAP = { - "current_humidity": SensorDeviceClass.HUMIDITY, - "current_temperature": SensorDeviceClass.TEMPERATURE, - "flow_rate": SensorDeviceClass.VOLUME_FLOW_RATE, - "water_pressure": SensorDeviceClass.PRESSURE, - "water_temperature": SensorDeviceClass.TEMPERATURE, -} - # Mapping for units that require conversion to a Home Assistant recognized unit # e.g. "m3/min" to "m³/min" DEVICE_UNIT_MAP = { diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index abf52f41393..f9c0288fa50 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -188,7 +188,7 @@ def async_setup_rpc_attribute_entities( # Filter non-existing sensors if description.role and description.role != coordinator.device.config[ key - ].get("role"): + ].get("role", "generic"): continue if description.sub_key not in coordinator.device.status[ diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index d852583c497..6e840bc67a6 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass -from typing import Final, cast +from functools import partial +from typing import Any, Final, cast from aioshelly.block_device import Block from aioshelly.const import RPC_GENERATIONS @@ -31,15 +31,18 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfFrequency, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfVolume, + UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP +from .const import CONF_SLEEP_PERIOD, LOGGER from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -78,7 +81,6 @@ class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): class RpcSensorDescription(RpcEntityDescription, SensorEntityDescription): """Class to describe a RPC sensor.""" - device_class_fn: Callable[[dict], SensorDeviceClass | None] | None = None emeter_phase: str | None = None @@ -105,12 +107,6 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): if self.option_map: self._attr_options = list(self.option_map.values()) - if description.device_class_fn is not None: - if device_class := description.device_class_fn( - coordinator.device.config[key] - ): - self._attr_device_class = device_class - @property def native_value(self) -> StateType: """Return value of sensor.""" @@ -1383,25 +1379,24 @@ RPC_SENSORS: Final = { ), unit=lambda config: config["xfreq"]["unit"] or None, ), - "text": RpcSensorDescription( + "text_generic": RpcSensorDescription( key="text", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( config, key, SENSOR_PLATFORM ), + role="generic", ), - "number": RpcSensorDescription( + "number_generic": RpcSensorDescription( key="number", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( config, key, SENSOR_PLATFORM ), unit=get_virtual_component_unit, - device_class_fn=lambda config: ROLE_TO_DEVICE_CLASS_MAP.get(config["role"]) - if "role" in config - else None, + role="generic", ), - "enum": RpcSensorDescription( + "enum_generic": RpcSensorDescription( key="enum", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( @@ -1409,6 +1404,7 @@ RPC_SENSORS: Final = { ), options_fn=lambda config: config["options"], device_class=SensorDeviceClass.ENUM, + role="generic", ), "valve_position": RpcSensorDescription( key="blutrv", @@ -1450,6 +1446,49 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENUM, options=["dark", "twilight", "bright"], ), + "number_current_humidity": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + role="current_humidity", + ), + "number_current_temperature": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + role="current_temperature", + ), + "number_flow_rate": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_MINUTE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + role="flow_rate", + ), + "number_water_pressure": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + role="water_pressure", + ), + "number_water_temperature": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + role="water_temperature", + ), "presence_num_objects": RpcSensorDescription( key="presence", sub_key="num_objects", @@ -1547,6 +1586,39 @@ RPC_SENSORS: Final = { } +@callback +def async_migrate_unique_ids( + coordinator: ShellyRpcCoordinator, + entity_entry: er.RegistryEntry, +) -> dict[str, Any] | None: + """Migrate sensor unique IDs to include role.""" + if not entity_entry.entity_id.startswith("sensor."): + return None + + for sensor_id in ("text", "number", "enum"): + old_unique_id = entity_entry.unique_id + if old_unique_id.endswith(f"-{sensor_id}"): + if entity_entry.original_device_class == SensorDeviceClass.HUMIDITY: + new_unique_id = f"{old_unique_id}_current_humidity" + elif entity_entry.original_device_class == SensorDeviceClass.TEMPERATURE: + new_unique_id = f"{old_unique_id}_current_temperature" + else: + new_unique_id = f"{old_unique_id}_generic" + LOGGER.debug( + "Migrating unique_id for %s entity from [%s] to [%s]", + entity_entry.entity_id, + old_unique_id, + new_unique_id, + ) + return { + "new_unique_id": entity_entry.unique_id.replace( + old_unique_id, new_unique_id + ) + } + + return None + + async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, @@ -1566,6 +1638,12 @@ async def async_setup_entry( coordinator = config_entry.runtime_data.rpc assert coordinator + await er.async_migrate_entries( + hass, + config_entry.entry_id, + partial(async_migrate_unique_ids, coordinator), + ) + async_setup_entry_rpc( hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor ) diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 408265d5320..1bf2a0e60a9 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1070,7 +1070,7 @@ async def test_rpc_device_virtual_text_sensor( assert state.state == "lorem ipsum" assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-text:203-text" + assert entry.unique_id == "123456789ABC-text:203-text_generic" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() @@ -1078,6 +1078,52 @@ async def test_rpc_device_virtual_text_sensor( assert state.state == "dolor sit amet" +@pytest.mark.parametrize( + ("old_id", "new_id", "device_class"), + [ + ("enum", "enum_generic", SensorDeviceClass.ENUM), + ("number", "number_generic", None), + ("number", "number_current_humidity", SensorDeviceClass.HUMIDITY), + ("number", "number_current_temperature", SensorDeviceClass.TEMPERATURE), + ("text", "text_generic", None), + ], +) +async def test_migrate_unique_id_virtual_components_roles( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + caplog: pytest.LogCaptureFixture, + old_id: str, + new_id: str, + device_class: SensorDeviceClass | None, +) -> None: + """Test migration of unique_id for virtual components to include role.""" + entry = await init_integration(hass, 3, skip_setup=True) + unique_base = f"{MOCK_MAC}-{old_id}:200" + old_unique_id = f"{unique_base}-{old_id}" + new_unique_id = f"{unique_base}-{new_id}" + + entity = entity_registry.async_get_or_create( + suggested_object_id="test_name_test_sensor", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + original_device_class=device_class, + ) + assert entity.unique_id == old_unique_id + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get("sensor.test_name_test_sensor") + assert entity_entry + assert entity_entry.unique_id == new_unique_id + + assert "Migrating unique_id for sensor.test_name_test_sensor" in caplog.text + + @pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_text_virtual_sensor_when_mode_field( hass: HomeAssistant, @@ -1101,7 +1147,7 @@ async def test_rpc_remove_text_virtual_sensor_when_mode_field( hass, SENSOR_DOMAIN, "test_name_text_200", - "text:200-text", + "text:200-text_generic", config_entry, device_id=device_entry.id, ) @@ -1125,7 +1171,7 @@ async def test_rpc_remove_text_virtual_sensor_when_orphaned( hass, SENSOR_DOMAIN, "test_name_text_200", - "text:200-text", + "text:200-text_generic", config_entry, device_id=device_entry.id, ) @@ -1175,7 +1221,7 @@ async def test_rpc_device_virtual_number_sensor( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-number:203-number" + assert entry.unique_id == "123456789ABC-number:203-number_generic" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) mock_rpc_device.mock_update() @@ -1211,7 +1257,7 @@ async def test_rpc_remove_number_virtual_sensor_when_mode_field( hass, SENSOR_DOMAIN, "test_name_number_200", - "number:200-number", + "number:200-number_generic", config_entry, device_id=device_entry.id, ) @@ -1235,7 +1281,7 @@ async def test_rpc_remove_number_virtual_sensor_when_orphaned( hass, SENSOR_DOMAIN, "test_name_number_200", - "number:200-number", + "number:200-number_generic", config_entry, device_id=device_entry.id, ) @@ -1289,7 +1335,7 @@ async def test_rpc_device_virtual_enum_sensor( assert state.attributes.get(ATTR_OPTIONS) == ["Title 1", "two", "three"] assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-enum:203-enum" + assert entry.unique_id == "123456789ABC-enum:203-enum_generic" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "two") mock_rpc_device.mock_update() @@ -1329,7 +1375,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( hass, SENSOR_DOMAIN, "test_name_enum_200", - "enum:200-enum", + "enum:200-enum_generic", config_entry, device_id=device_entry.id, ) @@ -1353,7 +1399,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_orphaned( hass, SENSOR_DOMAIN, "test_name_enum_200", - "enum:200-enum", + "enum:200-enum_generic", config_entry, device_id=device_entry.id, ) @@ -1516,8 +1562,10 @@ async def test_rpc_device_virtual_number_sensor_with_device_class( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, ) -> None: """Test a virtual number sensor with device class for RPC device.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_current_humidity" config = deepcopy(mock_rpc_device.config) config["number:203"] = { "name": "Current humidity", @@ -1534,7 +1582,10 @@ async def test_rpc_device_virtual_number_sensor_with_device_class( await init_integration(hass, 3) - assert (state := hass.states.get("sensor.test_name_current_humidity")) + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-number:203-number_current_humidity" + + assert (state := hass.states.get(entity_id)) assert state.state == "34" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY