From ad692f3341855a5191a994b73301e119dedccd85 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 25 Oct 2023 17:14:58 +1300 Subject: [PATCH] ESPHome Text entities (#102742) --- .../components/esphome/entry_data.py | 2 + .../components/esphome/manifest.json | 2 +- homeassistant/components/esphome/text.py | 63 ++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/test_text.py | 115 ++++++++++++++++++ 6 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/esphome/text.py create mode 100644 tests/components/esphome/test_text.py diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 21a8141647d..e53200c2e90 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -29,6 +29,7 @@ from aioesphomeapi import ( SensorInfo, SensorState, SwitchInfo, + TextInfo, TextSensorInfo, UserService, build_unique_id, @@ -68,6 +69,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { SelectInfo: Platform.SELECT, SensorInfo: Platform.SENSOR, SwitchInfo: Platform.SWITCH, + TextInfo: Platform.TEXT, TextSensorInfo: Platform.SENSOR, } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5e6d56b6ca2..702f75b166e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.12", + "aioesphomeapi==18.1.0", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py new file mode 100644 index 00000000000..49049eecfd4 --- /dev/null +++ b/homeassistant/components/esphome/text.py @@ -0,0 +1,63 @@ +"""Support for esphome texts.""" +from __future__ import annotations + +from aioesphomeapi import EntityInfo, TextInfo, TextMode as EsphomeTextMode, TextState + +from homeassistant.components.text import TextEntity, TextMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up esphome texts based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=TextInfo, + entity_type=EsphomeText, + state_type=TextState, + ) + + +TEXT_MODES: EsphomeEnumMapper[EsphomeTextMode, TextMode] = EsphomeEnumMapper( + { + EsphomeTextMode.TEXT: TextMode.TEXT, + EsphomeTextMode.PASSWORD: TextMode.PASSWORD, + } +) + + +class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): + """A text implementation for esphome.""" + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_native_min = static_info.min_length + self._attr_native_max = static_info.max_length + self._attr_pattern = static_info.pattern + self._attr_mode = TEXT_MODES.from_esphome(static_info.mode) or TextMode.TEXT + + @property + @esphome_state_property + def native_value(self) -> str | None: + """Return the state of the entity.""" + state = self._state + if state.missing_state: + return None + return state.state + + async def async_set_value(self, value: str) -> None: + """Update the current value.""" + await self._client.text_command(self._key, value) diff --git a/requirements_all.txt b/requirements_all.txt index a4c682fc5c8..a4c4edaef92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.12 +aioesphomeapi==18.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30e153543c6..765112d3dc5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.12 +aioesphomeapi==18.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py new file mode 100644 index 00000000000..07157d98ac6 --- /dev/null +++ b/tests/components/esphome/test_text.py @@ -0,0 +1,115 @@ +"""Test ESPHome texts.""" + +from unittest.mock import call + +from aioesphomeapi import APIClient, TextInfo, TextMode as ESPHomeTextMode, TextState + +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + + +async def test_generic_text_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [TextState(key=1, state="hello world")] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == "hello world" + + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "text.test_mytext", ATTR_VALUE: "goodbye"}, + blocking=True, + ) + mock_client.text_command.assert_has_calls([call(1, "goodbye")]) + mock_client.text_command.reset_mock() + + +async def test_generic_text_entity_no_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity that has no state.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_generic_text_entity_missing_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity that has no state.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [TextState(key=1, state="", missing_state=True)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == STATE_UNKNOWN