Add support for Shelly `number` virtual component (#121894)

* Support number component in field mode

* Support number in label mode

* Add tests

* Add mode_fn

* Add support for number component in slider mode

* Add comment

* Suggested change

* Revert max_fn

* Change unit 'min' to 'Hz' in test

---------

Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
pull/121973/head^2
Maciej Bieniek 2024-07-15 22:26:12 +02:00 committed by GitHub
parent a9bf12f102
commit 260e98c3f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 424 additions and 32 deletions

View File

@ -61,6 +61,7 @@ PLATFORMS: Final = [
Platform.COVER,
Platform.EVENT,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
Platform.TEXT,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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