diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index ecd827346b5..6f0f9e9cdbf 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -61,6 +61,7 @@ PLATFORMS: Final = [ Platform.COVER, Platform.EVENT, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, Platform.TEXT, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 5035877f3cf..b03452fa41f 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -27,6 +27,8 @@ from aioshelly.const import ( MODEL_WALL_DISPLAY, ) +from homeassistant.components.number import NumberMode + DOMAIN: Final = "shelly" LOGGER: Logger = getLogger(__package__) @@ -240,8 +242,14 @@ CONF_GEN = "gen" SHELLY_PLUS_RGBW_CHANNELS = 4 VIRTUAL_COMPONENTS_MAP = { - "binary_sensor": {"type": "boolean", "mode": "label"}, - "sensor": {"type": "text", "mode": "label"}, - "switch": {"type": "boolean", "mode": "toggle"}, - "text": {"type": "text", "mode": "field"}, + "binary_sensor": {"types": ["boolean"], "modes": ["label"]}, + "number": {"types": ["number"], "modes": ["field", "slider"]}, + "sensor": {"types": ["number", "text"], "modes": ["label"]}, + "switch": {"types": ["boolean"], "modes": ["toggle"]}, + "text": {"types": ["text"], "modes": ["field"]}, +} + +VIRTUAL_NUMBER_MODE_MAP = { + "field": NumberMode.BOX, + "slider": NumberMode.SLIDER, } diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 9f8b4c8d306..24e4f50d47e 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -291,6 +291,7 @@ class RpcEntityDescription(EntityDescription): extra_state_attributes: Callable[[dict, dict], dict | None] | None = None use_polling_coordinator: bool = False supported: Callable = lambda _: False + unit: Callable[[dict], str | None] | None = None @dataclass(frozen=True) @@ -508,6 +509,11 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity): id_key = key.split(":")[-1] self._id = int(id_key) if id_key.isnumeric() else None + if callable(description.unit): + self._attr_native_unit_of_measurement = description.unit( + coordinator.device.config[key] + ) + @property def sub_status(self) -> Any: """Device status by entity key.""" diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index afc508dd94f..67c33faf150 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -2,13 +2,17 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import TYPE_CHECKING, Any, Final, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( + DOMAIN as NUMBER_PLATFORM, + NumberEntity, NumberEntityDescription, NumberExtraStoredData, NumberMode, @@ -20,12 +24,20 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import CONF_SLEEP_PERIOD, LOGGER -from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry +from .const import CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, + RpcEntityDescription, + ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, + async_setup_entry_rpc, +) +from .utils import ( + async_remove_orphaned_virtual_entities, + get_device_entry_gen, + get_virtual_component_ids, ) @@ -37,6 +49,16 @@ class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): rest_arg: str = "" +@dataclass(frozen=True, kw_only=True) +class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription): + """Class to describe a RPC number entity.""" + + max_fn: Callable[[dict], float] | None = None + min_fn: Callable[[dict], float] | None = None + step_fn: Callable[[dict], float] | None = None + mode_fn: Callable[[dict], NumberMode] | None = None + + NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( key="device|valvepos", @@ -55,12 +77,54 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { } +RPC_NUMBERS: Final = { + "number": RpcNumberDescription( + key="number", + sub_key="value", + has_entity_name=True, + max_fn=lambda config: config["max"], + min_fn=lambda config: config["min"], + mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( + config["meta"]["ui"]["view"], NumberMode.BOX + ), + step_fn=lambda config: config["meta"]["ui"]["step"], + # If the unit is not set, the device sends an empty string + unit=lambda config: config["meta"]["ui"]["unit"] + if config["meta"]["ui"]["unit"] + else None, + ), +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up numbers for device.""" + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: + coordinator = config_entry.runtime_data.rpc + assert coordinator + + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_NUMBERS, RpcNumber + ) + + # the user can remove virtual components from the device configuration, so + # we need to remove orphaned entities + virtual_number_ids = get_virtual_component_ids( + coordinator.device.config, NUMBER_PLATFORM + ) + async_remove_orphaned_virtual_entities( + hass, + config_entry.entry_id, + coordinator.mac, + NUMBER_PLATFORM, + "number", + virtual_number_ids, + ) + return + if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_attribute_entities( hass, @@ -126,3 +190,44 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() + + +class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): + """Represent a RPC number entity.""" + + entity_description: RpcNumberDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcNumberDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, key, attribute, description) + + if callable(description.max_fn): + self._attr_native_max_value = description.max_fn( + coordinator.device.config[key] + ) + if callable(description.min_fn): + self._attr_native_min_value = description.min_fn( + coordinator.device.config[key] + ) + if callable(description.step_fn): + self._attr_native_step = description.step_fn(coordinator.device.config[key]) + if callable(description.mode_fn): + self._attr_mode = description.mode_fn(coordinator.device.config[key]) + + @property + def native_value(self) -> float | None: + """Return value of number.""" + if TYPE_CHECKING: + assert isinstance(self.attribute_value, float | None) + + return self.attribute_value + + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + await self.call_rpc("Number.Set", {"id": self._id, "value": value}) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 13c161f6c5c..cc782db6bad 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1024,6 +1024,14 @@ RPC_SENSORS: Final = { sub_key="value", has_entity_name=True, ), + "number": RpcSensorDescription( + key="number", + sub_key="value", + has_entity_name=True, + unit=lambda config: config["meta"]["ui"]["unit"] + if config["meta"]["ui"]["unit"] + else None, + ), } @@ -1052,17 +1060,18 @@ async def async_setup_entry( # the user can remove virtual components from the device configuration, so # we need to remove orphaned entities - virtual_sensor_ids = get_virtual_component_ids( - coordinator.device.config, SENSOR_PLATFORM - ) - async_remove_orphaned_virtual_entities( - hass, - config_entry.entry_id, - coordinator.mac, - SENSOR_PLATFORM, - "text", - virtual_sensor_ids, - ) + for component in ("text", "number"): + virtual_component_ids = get_virtual_component_ids( + coordinator.device.config, SENSOR_PLATFORM + ) + async_remove_orphaned_virtual_entities( + hass, + config_entry.entry_id, + coordinator.mac, + SENSOR_PLATFORM, + component, + virtual_component_ids, + ) return if config_entry.data[CONF_SLEEP_PERIOD]: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 5d6b00f3d65..4db5f9badbb 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -323,7 +323,7 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: return f"{device_name} {key.replace(':', '_')}" if key.startswith("em1"): return f"{device_name} EM{key.split(':')[-1]}" - if key.startswith(("boolean:", "text:")): + if key.startswith(("boolean:", "number:", "text:")): return key.replace(":", " ").title() return device_name @@ -524,12 +524,16 @@ def get_virtual_component_ids(config: dict[str, Any], platform: str) -> list[str if not component: return [] - return [ - k - for k, v in config.items() - if k.startswith(component["type"]) - and v["meta"]["ui"]["view"] == component["mode"] - ] + ids: list[str] = [] + + for comp_type in component["types"]: + ids.extend( + k + for k, v in config.items() + if k.startswith(comp_type) and v["meta"]["ui"]["view"] in component["modes"] + ) + + return ids @callback diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index ff453b3251c..73f432094b9 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -1,18 +1,24 @@ """Tests for Shelly number platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, Mock from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_MODE, + ATTR_STEP, ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, + NumberMode, ) from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry @@ -240,3 +246,145 @@ async def test_block_set_value_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +@pytest.mark.parametrize( + ("name", "entity_id", "original_unit", "expected_unit", "view", "mode"), + [ + ( + "Virtual number", + "number.test_name_virtual_number", + "%", + "%", + "field", + NumberMode.BOX, + ), + (None, "number.test_name_number_203", "", None, "field", NumberMode.BOX), + ( + "Virtual slider", + "number.test_name_virtual_slider", + "Hz", + "Hz", + "slider", + NumberMode.SLIDER, + ), + ], +) +async def test_rpc_device_virtual_number( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + name: str | None, + entity_id: str, + original_unit: str, + expected_unit: str | None, + view: str, + mode: NumberMode, +) -> None: + """Test a virtual number for RPC device.""" + config = deepcopy(mock_rpc_device.config) + config["number:203"] = { + "name": name, + "min": 0, + "max": 100, + "meta": {"ui": {"step": 0.1, "unit": original_unit, "view": view}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:203"] = {"value": 12.3} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + state = hass.states.get(entity_id) + assert state + assert state.state == "12.3" + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MAX) == 100 + assert state.attributes.get(ATTR_STEP) == 0.1 + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit + assert state.attributes.get(ATTR_MODE) is mode + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-number:203-number" + + monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 78.9) + mock_rpc_device.mock_update() + assert hass.states.get(entity_id).state == "78.9" + + monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 56.7}, + blocking=True, + ) + mock_rpc_device.mock_update() + assert hass.states.get(entity_id).state == "56.7" + + +async def test_rpc_remove_virtual_number_when_mode_label( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test if the virtual number will be removed if the mode has been changed to a label.""" + config = deepcopy(mock_rpc_device.config) + config["number:200"] = { + "name": None, + "min": -1000, + "max": 1000, + "meta": {"ui": {"step": 1, "unit": "", "view": "label"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:200"] = {"value": 123} + monkeypatch.setattr(mock_rpc_device, "status", status) + + config_entry = await init_integration(hass, 3, skip_setup=True) + device_entry = register_device(device_registry, config_entry) + entity_id = register_entity( + hass, + NUMBER_DOMAIN, + "test_name_number_200", + "number:200-number", + config_entry, + device_id=device_entry.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entity_id) + assert not entry + + +async def test_rpc_remove_virtual_number_when_orphaned( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Check whether the virtual number will be removed if it has been removed from the device configuration.""" + config_entry = await init_integration(hass, 3, skip_setup=True) + device_entry = register_device(device_registry, config_entry) + entity_id = register_entity( + hass, + NUMBER_DOMAIN, + "test_name_number_200", + "number:200-number", + config_entry, + device_id=device_entry.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entity_id) + assert not entry diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 51c88431d44..00b9d548dfd 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -863,7 +863,7 @@ async def test_rpc_disabled_xfreq( (None, "sensor.test_name_text_203"), ], ) -async def test_rpc_device_virtual_sensor( +async def test_rpc_device_virtual_text_sensor( hass: HomeAssistant, entity_registry: EntityRegistry, mock_rpc_device: Mock, @@ -871,7 +871,7 @@ async def test_rpc_device_virtual_sensor( name: str | None, entity_id: str, ) -> None: - """Test a virtual sensor for RPC device.""" + """Test a virtual text sensor for RPC device.""" config = deepcopy(mock_rpc_device.config) config["text:203"] = { "name": name, @@ -898,14 +898,14 @@ async def test_rpc_device_virtual_sensor( assert hass.states.get(entity_id).state == "dolor sit amet" -async def test_rpc_remove_virtual_sensor_when_mode_field( +async def test_rpc_remove_text_virtual_sensor_when_mode_field( hass: HomeAssistant, entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test if the virtual sensor will be removed if the mode has been changed to a field.""" + """Test if the virtual text sensor will be removed if the mode has been changed to a field.""" config = deepcopy(mock_rpc_device.config) config["text:200"] = {"name": None, "meta": {"ui": {"view": "field"}}} monkeypatch.setattr(mock_rpc_device, "config", config) @@ -932,13 +932,13 @@ async def test_rpc_remove_virtual_sensor_when_mode_field( assert not entry -async def test_rpc_remove_virtual_sensor_when_orphaned( +async def test_rpc_remove_text_virtual_sensor_when_orphaned( hass: HomeAssistant, entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, ) -> None: - """Check whether the virtual sensor will be removed if it has been removed from the device configuration.""" + """Check whether the virtual text sensor will be removed if it has been removed from the device configuration.""" config_entry = await init_integration(hass, 3, skip_setup=True) device_entry = register_device(device_registry, config_entry) entity_id = register_entity( @@ -955,3 +955,114 @@ async def test_rpc_remove_virtual_sensor_when_orphaned( entry = entity_registry.async_get(entity_id) assert not entry + + +@pytest.mark.parametrize( + ("name", "entity_id", "original_unit", "expected_unit"), + [ + ("Virtual number sensor", "sensor.test_name_virtual_number_sensor", "W", "W"), + (None, "sensor.test_name_number_203", "", None), + ], +) +async def test_rpc_device_virtual_number_sensor( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + name: str | None, + entity_id: str, + original_unit: str, + expected_unit: str | None, +) -> None: + """Test a virtual number sensor for RPC device.""" + config = deepcopy(mock_rpc_device.config) + config["number:203"] = { + "name": name, + "min": 0, + "max": 100, + "meta": {"ui": {"step": 0.1, "unit": original_unit, "view": "label"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:203"] = {"value": 34.5} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + state = hass.states.get(entity_id) + assert state + assert state.state == "34.5" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-number:203-number" + + monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) + mock_rpc_device.mock_update() + assert hass.states.get(entity_id).state == "56.7" + + +async def test_rpc_remove_number_virtual_sensor_when_mode_field( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test if the virtual number sensor will be removed if the mode has been changed to a field.""" + config = deepcopy(mock_rpc_device.config) + config["number:200"] = { + "name": None, + "min": 0, + "max": 100, + "meta": {"ui": {"step": 1, "unit": "", "view": "field"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:200"] = {"value": 67.8} + monkeypatch.setattr(mock_rpc_device, "status", status) + + config_entry = await init_integration(hass, 3, skip_setup=True) + device_entry = register_device(device_registry, config_entry) + entity_id = register_entity( + hass, + SENSOR_DOMAIN, + "test_name_number_200", + "number:200-number", + config_entry, + device_id=device_entry.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entity_id) + assert not entry + + +async def test_rpc_remove_number_virtual_sensor_when_orphaned( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Check whether the virtual number sensor will be removed if it has been removed from the device configuration.""" + config_entry = await init_integration(hass, 3, skip_setup=True) + device_entry = register_device(device_registry, config_entry) + entity_id = register_entity( + hass, + SENSOR_DOMAIN, + "test_name_number_200", + "number:200-number", + config_entry, + device_id=device_entry.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entity_id) + assert not entry