Use component role in Shelly sensor platform (#152710)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Shay Levy
2025-09-22 01:03:29 +03:00
committed by GitHub
parent 1e14fb6dab
commit 181741cab6
4 changed files with 157 additions and 37 deletions

View File

@@ -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 = {

View File

@@ -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[

View File

@@ -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
)

View File

@@ -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