From ca6aac7d5777080dea84c2fd6cec9539db06c951 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 22 Feb 2025 08:04:51 +0000 Subject: [PATCH 1/4] Add androidtv_remote.send_text service --- .../components/androidtv_remote/icons.json | 7 +++++ .../components/androidtv_remote/manifest.json | 2 +- .../components/androidtv_remote/remote.py | 24 +++++++++++++++ .../components/androidtv_remote/services.yaml | 11 +++++++ .../components/androidtv_remote/strings.json | 12 ++++++++ .../default_config/manifest.original.json | 29 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../androidtv_remote/test_remote.py | 27 +++++++++++++++++ 9 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/androidtv_remote/icons.json create mode 100644 homeassistant/components/androidtv_remote/services.yaml create mode 100644 homeassistant/components/default_config/manifest.original.json diff --git a/homeassistant/components/androidtv_remote/icons.json b/homeassistant/components/androidtv_remote/icons.json new file mode 100644 index 00000000000000..a725896d13327a --- /dev/null +++ b/homeassistant/components/androidtv_remote/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "send_text": { + "service": "mdi:keyboard" + } + } +} diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index d9c2dd05c4464e..1c45e825359882 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.1.2"], + "requirements": ["androidtvremote2==0.2.0"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 212b0491d2de40..45a3fa4f6f8c84 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -6,6 +6,9 @@ from collections.abc import Iterable from typing import Any +from androidtvremote2 import ConnectionClosed +import voluptuous as vol + from homeassistant.components.remote import ( ATTR_ACTIVITY, ATTR_DELAY_SECS, @@ -18,6 +21,8 @@ RemoteEntityFeature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AndroidTVRemoteConfigEntry @@ -27,6 +32,10 @@ PARALLEL_UPDATES = 0 +SERVICE_SEND_TEXT = "send_text" +ATTR_TEXT = "text" + + async def async_setup_entry( hass: HomeAssistant, config_entry: AndroidTVRemoteConfigEntry, @@ -35,6 +44,12 @@ async def async_setup_entry( """Set up the Android TV remote entity based on a config entry.""" api = config_entry.runtime_data async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_SEND_TEXT, + {vol.Required(ATTR_TEXT): cv.string}, + "send_text", + ) class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): @@ -108,3 +123,12 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non else: self._send_key_command(single_command, "SHORT") await asyncio.sleep(delay_secs) + + async def send_text(self, text: str) -> None: + """Send text to Android TV.""" + try: + self._api.send_text(text) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/services.yaml b/homeassistant/components/androidtv_remote/services.yaml new file mode 100644 index 00000000000000..b65a062215db08 --- /dev/null +++ b/homeassistant/components/androidtv_remote/services.yaml @@ -0,0 +1,11 @@ +send_text: + target: + entity: + integration: androidtv_remote + domain: remote + fields: + text: + required: true + example: "hello world" + selector: + text: diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index e41cbcf9a762a0..472ce72f36e303 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -54,5 +54,17 @@ } } } + }, + "services": { + "send_text": { + "name": "Send text", + "description": "Sends text to an Android device.", + "fields": { + "text": { + "name": "Text", + "description": "Text to 'type'" + } + } + } } } diff --git a/homeassistant/components/default_config/manifest.original.json b/homeassistant/components/default_config/manifest.original.json new file mode 100644 index 00000000000000..8299fe43f0966e --- /dev/null +++ b/homeassistant/components/default_config/manifest.original.json @@ -0,0 +1,29 @@ +{ + "domain": "default_config", + "name": "Default Config", + "codeowners": ["@home-assistant/core"], + "dependencies": [ + "assist_pipeline", + "bluetooth", + "cloud", + "conversation", + "dhcp", + "energy", + "go2rtc", + "history", + "homeassistant_alerts", + "logbook", + "media_source", + "mobile_app", + "my", + "ssdp", + "stream", + "sun", + "usb", + "webhook", + "zeroconf" + ], + "documentation": "https://www.home-assistant.io/integrations/default_config", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/requirements_all.txt b/requirements_all.txt index 6b754d8bf59fac..2453594d293180 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,7 +461,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7b8120c99174d..40444fab80dfea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anova anova-wifi==0.17.0 diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index b3c3ce1c2834d7..b42906a893e1dc 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -174,6 +174,23 @@ async def test_remote_send_command_with_hold_secs( ] +async def test_send_text( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test sending text via the `androidtv_remote.send_text` service.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "androidtv_remote", + "send_text", + {"entity_id": REMOTE_ENTITY, "text": "hello world"}, + blocking=True, + ) + assert mock_api.send_text.mock_calls == [call("hello world")] + + async def test_remote_connection_closed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: @@ -205,3 +222,13 @@ async def test_remote_connection_closed( blocking=True, ) assert mock_api.send_launch_app_command.mock_calls == [call("activity1")] + + mock_api.send_text.side_effect = ConnectionClosed() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "androidtv_remote", + "send_text", + {"entity_id": REMOTE_ENTITY, "text": "hello world"}, + blocking=True, + ) + assert mock_api.send_text.mock_calls == [call("hello world")] From 3dd72db0b1d09e7e0eb3fd8e157ab08b1c581808 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 22 Feb 2025 00:26:37 -0800 Subject: [PATCH 2/4] delete --- .../default_config/manifest.original.json | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 homeassistant/components/default_config/manifest.original.json diff --git a/homeassistant/components/default_config/manifest.original.json b/homeassistant/components/default_config/manifest.original.json deleted file mode 100644 index 8299fe43f0966e..00000000000000 --- a/homeassistant/components/default_config/manifest.original.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "domain": "default_config", - "name": "Default Config", - "codeowners": ["@home-assistant/core"], - "dependencies": [ - "assist_pipeline", - "bluetooth", - "cloud", - "conversation", - "dhcp", - "energy", - "go2rtc", - "history", - "homeassistant_alerts", - "logbook", - "media_source", - "mobile_app", - "my", - "ssdp", - "stream", - "sun", - "usb", - "webhook", - "zeroconf" - ], - "documentation": "https://www.home-assistant.io/integrations/default_config", - "integration_type": "system", - "quality_scale": "internal" -} From 5d58778569f0199c7797384c805c8dadf4b1cb5d Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 23 Feb 2025 22:08:18 +0000 Subject: [PATCH 3/4] update string --- homeassistant/components/androidtv_remote/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 472ce72f36e303..529148d4593cba 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -58,11 +58,11 @@ "services": { "send_text": { "name": "Send text", - "description": "Sends text to an Android device.", + "description": "Sends text to an Android TV device.", "fields": { "text": { "name": "Text", - "description": "Text to 'type'" + "description": "Text to send as input to device." } } } From 4e99220c17a5f1cfe2fc949e5d44378761332461 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 8 Apr 2025 05:59:37 +0000 Subject: [PATCH 4/4] integration service --- .../components/androidtv_remote/__init__.py | 13 +-- .../components/androidtv_remote/const.py | 6 + .../components/androidtv_remote/remote.py | 24 ---- .../components/androidtv_remote/services.py | 60 ++++++++++ .../components/androidtv_remote/services.yaml | 9 +- .../components/androidtv_remote/strings.json | 12 ++ .../androidtv_remote/test_remote.py | 27 ----- .../androidtv_remote/test_services.py | 104 ++++++++++++++++++ 8 files changed, 192 insertions(+), 63 deletions(-) create mode 100644 homeassistant/components/androidtv_remote/services.py create mode 100644 tests/components/androidtv_remote/test_services.py diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 28a372da4eab7f..c98a07637b81dc 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -5,26 +5,21 @@ from asyncio import timeout import logging -from androidtvremote2 import ( - AndroidTVRemote, - CannotConnect, - ConnectionClosed, - InvalidAuth, -) +from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from .const import AndroidTVRemoteConfigEntry from .helpers import create_api, get_enable_ime +from .services import async_register_services _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE] -AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote] - async def async_setup_entry( hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry @@ -79,6 +74,8 @@ def on_hass_stop(event: Event) -> None: entry.async_on_unload(entry.add_update_listener(async_update_options)) entry.async_on_unload(api.disconnect) + async_register_services(hass) + return True diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py index 540c8186e204e0..409fd4519c8938 100644 --- a/homeassistant/components/androidtv_remote/const.py +++ b/homeassistant/components/androidtv_remote/const.py @@ -4,6 +4,10 @@ from typing import Final +from androidtvremote2 import AndroidTVRemote + +from homeassistant.config_entries import ConfigEntry + DOMAIN: Final = "androidtv_remote" CONF_APPS = "apps" @@ -11,3 +15,5 @@ CONF_ENABLE_IME_DEFAULT_VALUE: Final = True CONF_APP_NAME = "app_name" CONF_APP_ICON = "app_icon" + +AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote] diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 45a3fa4f6f8c84..212b0491d2de40 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -6,9 +6,6 @@ from collections.abc import Iterable from typing import Any -from androidtvremote2 import ConnectionClosed -import voluptuous as vol - from homeassistant.components.remote import ( ATTR_ACTIVITY, ATTR_DELAY_SECS, @@ -21,8 +18,6 @@ RemoteEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AndroidTVRemoteConfigEntry @@ -32,10 +27,6 @@ PARALLEL_UPDATES = 0 -SERVICE_SEND_TEXT = "send_text" -ATTR_TEXT = "text" - - async def async_setup_entry( hass: HomeAssistant, config_entry: AndroidTVRemoteConfigEntry, @@ -44,12 +35,6 @@ async def async_setup_entry( """Set up the Android TV remote entity based on a config entry.""" api = config_entry.runtime_data async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_SEND_TEXT, - {vol.Required(ATTR_TEXT): cv.string}, - "send_text", - ) class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): @@ -123,12 +108,3 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non else: self._send_key_command(single_command, "SHORT") await asyncio.sleep(delay_secs) - - async def send_text(self, text: str) -> None: - """Send text to Android TV.""" - try: - self._api.send_text(text) - except ConnectionClosed as exc: - raise HomeAssistantError( - "Connection to Android TV device is closed" - ) from exc diff --git a/homeassistant/components/androidtv_remote/services.py b/homeassistant/components/androidtv_remote/services.py new file mode 100644 index 00000000000000..4c6651ae11bacc --- /dev/null +++ b/homeassistant/components/androidtv_remote/services.py @@ -0,0 +1,60 @@ +"""Android TV Remote services.""" + +from __future__ import annotations + +from androidtvremote2 import ConnectionClosed +import voluptuous as vol + +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, AndroidTVRemoteConfigEntry + +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_TEXT = "text" +SEND_TEXT_SERVICE = "send_text" +SEND_TEXT_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_TEXT): cv.string, + } +) + + +def async_register_services(hass: HomeAssistant) -> None: + """Register Android TV Remote services.""" + + async def async_handle_send_text(call: ServiceCall) -> ServiceResponse: + """Send text.""" + config_entry: AndroidTVRemoteConfigEntry | None = ( + hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + api = config_entry.runtime_data + try: + api.send_text(call.data[CONF_TEXT]) + except ConnectionClosed as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="connection_closed" + ) from exc + return None + + if not hass.services.has_service(DOMAIN, SEND_TEXT_SERVICE): + hass.services.async_register( + DOMAIN, + SEND_TEXT_SERVICE, + async_handle_send_text, + schema=SEND_TEXT_SERVICE_SCHEMA, + supports_response=SupportsResponse.NONE, + ) diff --git a/homeassistant/components/androidtv_remote/services.yaml b/homeassistant/components/androidtv_remote/services.yaml index b65a062215db08..f31b3758eec9bc 100644 --- a/homeassistant/components/androidtv_remote/services.yaml +++ b/homeassistant/components/androidtv_remote/services.yaml @@ -1,9 +1,10 @@ send_text: - target: - entity: - integration: androidtv_remote - domain: remote fields: + config_entry_id: + required: true + selector: + config_entry: + integration: androidtv_remote text: required: true example: "hello world" diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 529148d4593cba..1d4240fcd23dea 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -55,11 +55,23 @@ } } }, + "exceptions": { + "connection_closed": { + "message": "Connection to the Android TV device is closed" + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + } + }, "services": { "send_text": { "name": "Send text", "description": "Sends text to an Android TV device.", "fields": { + "config_entry_id": { + "name": "Integration ID", + "description": "The Android TV Remote integration ID." + }, "text": { "name": "Text", "description": "Text to send as input to device." diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index b42906a893e1dc..b3c3ce1c2834d7 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -174,23 +174,6 @@ async def test_remote_send_command_with_hold_secs( ] -async def test_send_text( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock -) -> None: - """Test sending text via the `androidtv_remote.send_text` service.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.state is ConfigEntryState.LOADED - - await hass.services.async_call( - "androidtv_remote", - "send_text", - {"entity_id": REMOTE_ENTITY, "text": "hello world"}, - blocking=True, - ) - assert mock_api.send_text.mock_calls == [call("hello world")] - - async def test_remote_connection_closed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: @@ -222,13 +205,3 @@ async def test_remote_connection_closed( blocking=True, ) assert mock_api.send_launch_app_command.mock_calls == [call("activity1")] - - mock_api.send_text.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "androidtv_remote", - "send_text", - {"entity_id": REMOTE_ENTITY, "text": "hello world"}, - blocking=True, - ) - assert mock_api.send_text.mock_calls == [call("hello world")] diff --git a/tests/components/androidtv_remote/test_services.py b/tests/components/androidtv_remote/test_services.py new file mode 100644 index 00000000000000..076a18a5f2e152 --- /dev/null +++ b/tests/components/androidtv_remote/test_services.py @@ -0,0 +1,104 @@ +"""Tests for Android TV Remote services.""" + +from unittest.mock import MagicMock, call + +from androidtvremote2 import ConnectionClosed +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + +TEST_TEXT = "Hello World" + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Android TV Remote integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + assert hass.services.has_service("androidtv_remote", "send_text") + + +async def test_send_text_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test service call to send_text.""" + await setup_integration(hass, mock_config_entry) + response = await hass.services.async_call( + "androidtv_remote", + "send_text", + { + "config_entry_id": mock_config_entry.entry_id, + "text": TEST_TEXT, + }, + blocking=True, + ) + assert response is None + assert mock_api.send_text.mock_calls == [call(TEST_TEXT)] + + +async def test_send_text_service_config_entry_not_found( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test send_text service call with a config entry that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + "androidtv_remote", + "send_text", + { + "config_entry_id": "invalid-config-entry-id", + "text": TEST_TEXT, + }, + blocking=True, + ) + + +async def test_config_entry_not_loaded( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test send_text service call with a config entry that is not loaded.""" + await setup_integration(hass, mock_config_entry) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + "androidtv_remote", + "send_text", + { + "config_entry_id": mock_config_entry.unique_id, + "text": TEST_TEXT, + }, + blocking=True, + ) + + +async def test_send_text_service_fails( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test service call to send_text fails.""" + await setup_integration(hass, mock_config_entry) + mock_api.send_text.side_effect = ConnectionClosed() + + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): + await hass.services.async_call( + "androidtv_remote", + "send_text", + { + "config_entry_id": mock_config_entry.entry_id, + "text": TEST_TEXT, + }, + blocking=True, + ) + assert mock_api.send_text.mock_calls == [call(TEST_TEXT)]