From 3ef1e5816e342a8570bf9033208470d6b21dafb0 Mon Sep 17 00:00:00 2001
From: Maciej Bieniek <bieniu@users.noreply.github.com>
Date: Fri, 12 Jul 2024 12:58:21 +0200
Subject: [PATCH] Add support for Shelly `text` virtual component (#121735)

* Add support for text component

* Add tests

* Improve const names

* Remove unnecessary code

---------

Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
---
 homeassistant/components/shelly/__init__.py |   1 +
 homeassistant/components/shelly/const.py    |   2 +
 homeassistant/components/shelly/sensor.py   |  25 ++++
 homeassistant/components/shelly/text.py     |  89 ++++++++++++++
 homeassistant/components/shelly/utils.py    |   2 +-
 tests/components/shelly/test_sensor.py      | 101 +++++++++++++++
 tests/components/shelly/test_text.py        | 129 ++++++++++++++++++++
 7 files changed, 348 insertions(+), 1 deletion(-)
 create mode 100644 homeassistant/components/shelly/text.py
 create mode 100644 tests/components/shelly/test_text.py

diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py
index 75f66d0bced..ecd827346b5 100644
--- a/homeassistant/components/shelly/__init__.py
+++ b/homeassistant/components/shelly/__init__.py
@@ -63,6 +63,7 @@ PLATFORMS: Final = [
     Platform.LIGHT,
     Platform.SENSOR,
     Platform.SWITCH,
+    Platform.TEXT,
     Platform.UPDATE,
     Platform.VALVE,
 ]
diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py
index 837e7abfca1..5035877f3cf 100644
--- a/homeassistant/components/shelly/const.py
+++ b/homeassistant/components/shelly/const.py
@@ -241,5 +241,7 @@ 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"},
 }
diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py
index 5a6f03fd90c..13c161f6c5c 100644
--- a/homeassistant/components/shelly/sensor.py
+++ b/homeassistant/components/shelly/sensor.py
@@ -9,6 +9,7 @@ from aioshelly.block_device import Block
 from aioshelly.const import RPC_GENERATIONS
 
 from homeassistant.components.sensor import (
+    DOMAIN as SENSOR_PLATFORM,
     RestoreSensor,
     SensorDeviceClass,
     SensorEntity,
@@ -52,8 +53,10 @@ from .entity import (
     async_setup_entry_rpc,
 )
 from .utils import (
+    async_remove_orphaned_virtual_entities,
     get_device_entry_gen,
     get_device_uptime,
+    get_virtual_component_ids,
     is_rpc_wifi_stations_disabled,
 )
 
@@ -1016,6 +1019,11 @@ RPC_SENSORS: Final = {
             or status[key].get("xfreq") is None
         ),
     ),
+    "text": RpcSensorDescription(
+        key="text",
+        sub_key="value",
+        has_entity_name=True,
+    ),
 }
 
 
@@ -1035,9 +1043,26 @@ async def async_setup_entry(
                 RpcSleepingSensor,
             )
         else:
+            coordinator = config_entry.runtime_data.rpc
+            assert coordinator
+
             async_setup_entry_rpc(
                 hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor
             )
+
+            # 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,
+            )
         return
 
     if config_entry.data[CONF_SLEEP_PERIOD]:
diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py
new file mode 100644
index 00000000000..ec290def45d
--- /dev/null
+++ b/homeassistant/components/shelly/text.py
@@ -0,0 +1,89 @@
+"""Text for Shelly."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Final
+
+from aioshelly.const import RPC_GENERATIONS
+
+from homeassistant.components.text import (
+    DOMAIN as TEXT_PLATFORM,
+    TextEntity,
+    TextEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import ShellyConfigEntry
+from .entity import (
+    RpcEntityDescription,
+    ShellyRpcAttributeEntity,
+    async_setup_entry_rpc,
+)
+from .utils import (
+    async_remove_orphaned_virtual_entities,
+    get_device_entry_gen,
+    get_virtual_component_ids,
+)
+
+
+@dataclass(frozen=True, kw_only=True)
+class RpcTextDescription(RpcEntityDescription, TextEntityDescription):
+    """Class to describe a RPC text entity."""
+
+
+RPC_TEXT_ENTITIES: Final = {
+    "text": RpcTextDescription(
+        key="text",
+        sub_key="value",
+        has_entity_name=True,
+    ),
+}
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    config_entry: ShellyConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+) -> None:
+    """Set up sensors 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_TEXT_ENTITIES, RpcText
+        )
+
+        # the user can remove virtual components from the device configuration, so
+        # we need to remove orphaned entities
+        virtual_text_ids = get_virtual_component_ids(
+            coordinator.device.config, TEXT_PLATFORM
+        )
+        async_remove_orphaned_virtual_entities(
+            hass,
+            config_entry.entry_id,
+            coordinator.mac,
+            TEXT_PLATFORM,
+            "text",
+            virtual_text_ids,
+        )
+
+
+class RpcText(ShellyRpcAttributeEntity, TextEntity):
+    """Represent a RPC text entity."""
+
+    entity_description: RpcTextDescription
+
+    @property
+    def native_value(self) -> str | None:
+        """Return value of sensor."""
+        if TYPE_CHECKING:
+            assert isinstance(self.attribute_value, str | None)
+
+        return self.attribute_value
+
+    async def async_set_value(self, value: str) -> None:
+        """Change the value."""
+        await self.call_rpc("Text.Set", {"id": self._id, "value": value})
diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py
index a1d357e3beb..d5c803716e8 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:"):
+        if key.startswith(("boolean:", "text:")):
             return key.replace(":", " ").title()
         return device_name
 
diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py
index c62a1f6f6ca..51c88431d44 100644
--- a/tests/components/shelly/test_sensor.py
+++ b/tests/components/shelly/test_sensor.py
@@ -854,3 +854,104 @@ async def test_rpc_disabled_xfreq(
 
     entry = entity_registry.async_get(entity_id)
     assert not entry
+
+
+@pytest.mark.parametrize(
+    ("name", "entity_id"),
+    [
+        ("Virtual sensor", "sensor.test_name_virtual_sensor"),
+        (None, "sensor.test_name_text_203"),
+    ],
+)
+async def test_rpc_device_virtual_sensor(
+    hass: HomeAssistant,
+    entity_registry: EntityRegistry,
+    mock_rpc_device: Mock,
+    monkeypatch: pytest.MonkeyPatch,
+    name: str | None,
+    entity_id: str,
+) -> None:
+    """Test a virtual sensor for RPC device."""
+    config = deepcopy(mock_rpc_device.config)
+    config["text:203"] = {
+        "name": name,
+        "meta": {"ui": {"view": "label"}},
+    }
+    monkeypatch.setattr(mock_rpc_device, "config", config)
+
+    status = deepcopy(mock_rpc_device.status)
+    status["text:203"] = {"value": "lorem ipsum"}
+    monkeypatch.setattr(mock_rpc_device, "status", status)
+
+    await init_integration(hass, 3)
+
+    state = hass.states.get(entity_id)
+    assert state
+    assert state.state == "lorem ipsum"
+
+    entry = entity_registry.async_get(entity_id)
+    assert entry
+    assert entry.unique_id == "123456789ABC-text:203-text"
+
+    monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet")
+    mock_rpc_device.mock_update()
+    assert hass.states.get(entity_id).state == "dolor sit amet"
+
+
+async def test_rpc_remove_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."""
+    config = deepcopy(mock_rpc_device.config)
+    config["text:200"] = {"name": None, "meta": {"ui": {"view": "field"}}}
+    monkeypatch.setattr(mock_rpc_device, "config", config)
+
+    status = deepcopy(mock_rpc_device.status)
+    status["text:200"] = {"value": "lorem ipsum"}
+    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_text_200",
+        "text:200-text",
+        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_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."""
+    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_text_200",
+        "text:200-text",
+        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_text.py b/tests/components/shelly/test_text.py
new file mode 100644
index 00000000000..19acb856f35
--- /dev/null
+++ b/tests/components/shelly/test_text.py
@@ -0,0 +1,129 @@
+"""Tests for Shelly text platform."""
+
+from copy import deepcopy
+from unittest.mock import Mock
+
+import pytest
+
+from homeassistant.components.text import (
+    ATTR_VALUE,
+    DOMAIN as TEXT_PLATFORM,
+    SERVICE_SET_VALUE,
+)
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceRegistry
+from homeassistant.helpers.entity_registry import EntityRegistry
+
+from . import init_integration, register_device, register_entity
+
+
+@pytest.mark.parametrize(
+    ("name", "entity_id"),
+    [
+        ("Virtual text", "text.test_name_virtual_text"),
+        (None, "text.test_name_text_203"),
+    ],
+)
+async def test_rpc_device_virtual_text(
+    hass: HomeAssistant,
+    entity_registry: EntityRegistry,
+    mock_rpc_device: Mock,
+    monkeypatch: pytest.MonkeyPatch,
+    name: str | None,
+    entity_id: str,
+) -> None:
+    """Test a virtual text for RPC device."""
+    config = deepcopy(mock_rpc_device.config)
+    config["text:203"] = {
+        "name": name,
+        "meta": {"ui": {"view": "field"}},
+    }
+    monkeypatch.setattr(mock_rpc_device, "config", config)
+
+    status = deepcopy(mock_rpc_device.status)
+    status["text:203"] = {"value": "lorem ipsum"}
+    monkeypatch.setattr(mock_rpc_device, "status", status)
+
+    await init_integration(hass, 3)
+
+    state = hass.states.get(entity_id)
+    assert state
+    assert state.state == "lorem ipsum"
+
+    entry = entity_registry.async_get(entity_id)
+    assert entry
+    assert entry.unique_id == "123456789ABC-text:203-text"
+
+    monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet")
+    mock_rpc_device.mock_update()
+    assert hass.states.get(entity_id).state == "dolor sit amet"
+
+    monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "sed do eiusmod")
+    await hass.services.async_call(
+        TEXT_PLATFORM,
+        SERVICE_SET_VALUE,
+        {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: "sed do eiusmod"},
+        blocking=True,
+    )
+    mock_rpc_device.mock_update()
+    assert hass.states.get(entity_id).state == "sed do eiusmod"
+
+
+async def test_rpc_remove_virtual_text_when_mode_label(
+    hass: HomeAssistant,
+    entity_registry: EntityRegistry,
+    device_registry: DeviceRegistry,
+    mock_rpc_device: Mock,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """Test if the virtual text will be removed if the mode has been changed to a label."""
+    config = deepcopy(mock_rpc_device.config)
+    config["text:200"] = {"name": None, "meta": {"ui": {"view": "label"}}}
+    monkeypatch.setattr(mock_rpc_device, "config", config)
+
+    status = deepcopy(mock_rpc_device.status)
+    status["text:200"] = {"value": "lorem ipsum"}
+    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,
+        TEXT_PLATFORM,
+        "test_name_text_200",
+        "text:200-text",
+        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_text_when_orphaned(
+    hass: HomeAssistant,
+    entity_registry: EntityRegistry,
+    device_registry: DeviceRegistry,
+    mock_rpc_device: Mock,
+) -> None:
+    """Check whether the virtual text 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,
+        TEXT_PLATFORM,
+        "test_name_text_200",
+        "text:200-text",
+        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
-- 
GitLab