From 51977227332ab3ece709eb0b7060eb7d6ec03d96 Mon Sep 17 00:00:00 2001 From: Keith Roehrenbeck Date: Wed, 1 Apr 2026 07:05:20 -0500 Subject: [PATCH] Add keyboard text input services to Apple TV integration (#165638) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/apple_tv/__init__.py | 12 +- homeassistant/components/apple_tv/const.py | 2 + homeassistant/components/apple_tv/icons.json | 11 ++ homeassistant/components/apple_tv/services.py | 130 +++++++++++++ .../components/apple_tv/services.yaml | 31 +++ .../components/apple_tv/strings.json | 54 ++++++ tests/components/apple_tv/test_services.py | 181 ++++++++++++++++++ 7 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/apple_tv/services.py create mode 100644 homeassistant/components/apple_tv/services.yaml create mode 100644 tests/components/apple_tv/test_services.py diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 09b11f555cf..0e2914a0eaa 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -30,9 +30,10 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CREDENTIALS, @@ -42,9 +43,12 @@ from .const import ( SIGNAL_CONNECTED, SIGNAL_DISCONNECTED, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + DEFAULT_NAME_TV = "Apple TV" DEFAULT_NAME_HP = "HomePod" @@ -77,6 +81,12 @@ DEVICE_EXCEPTIONS = ( type AppleTvConfigEntry = ConfigEntry[AppleTVManager] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Apple TV component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool: """Set up a config entry for Apple TV.""" manager = AppleTVManager(hass, entry) diff --git a/homeassistant/components/apple_tv/const.py b/homeassistant/components/apple_tv/const.py index dd215337f1c..eefd90eef64 100644 --- a/homeassistant/components/apple_tv/const.py +++ b/homeassistant/components/apple_tv/const.py @@ -9,3 +9,5 @@ CONF_START_OFF = "start_off" SIGNAL_CONNECTED = "apple_tv_connected" SIGNAL_DISCONNECTED = "apple_tv_disconnected" + +ATTR_TEXT = "text" diff --git a/homeassistant/components/apple_tv/icons.json b/homeassistant/components/apple_tv/icons.json index 8acb855e3c7..96aec31cc77 100644 --- a/homeassistant/components/apple_tv/icons.json +++ b/homeassistant/components/apple_tv/icons.json @@ -8,5 +8,16 @@ } } } + }, + "services": { + "append_keyboard_text": { + "service": "mdi:keyboard" + }, + "clear_keyboard_text": { + "service": "mdi:keyboard-off" + }, + "set_keyboard_text": { + "service": "mdi:keyboard" + } } } diff --git a/homeassistant/components/apple_tv/services.py b/homeassistant/components/apple_tv/services.py new file mode 100644 index 00000000000..cdf659796da --- /dev/null +++ b/homeassistant/components/apple_tv/services.py @@ -0,0 +1,130 @@ +"""Define services for the Apple TV integration.""" + +from __future__ import annotations + +from pyatv.const import KeyboardFocusState +from pyatv.exceptions import NotSupportedError, ProtocolError +from pyatv.interface import AppleTV as AppleTVInterface +import voluptuous as vol + +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, service + +from .const import ATTR_TEXT, DOMAIN + +SERVICE_SET_KEYBOARD_TEXT = "set_keyboard_text" +SERVICE_SET_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_TEXT): cv.string, + } +) + +SERVICE_APPEND_KEYBOARD_TEXT = "append_keyboard_text" +SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_TEXT): cv.string, + } +) + +SERVICE_CLEAR_KEYBOARD_TEXT = "clear_keyboard_text" +SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + } +) + + +def _get_atv(call: ServiceCall) -> AppleTVInterface: + """Get the AppleTVInterface for a service call.""" + entry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + atv: AppleTVInterface | None = entry.runtime_data.atv + if atv is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_connected", + ) + return atv + + +def _check_keyboard_focus(atv: AppleTVInterface) -> None: + """Check that keyboard is focused on the device.""" + try: + focus_state = atv.keyboard.text_focus_state + except NotSupportedError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="keyboard_not_available", + ) from err + if focus_state != KeyboardFocusState.Focused: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="keyboard_not_focused", + ) + + +async def _async_set_keyboard_text(call: ServiceCall) -> None: + """Set text in the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_set(call.data[ATTR_TEXT]) + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +async def _async_append_keyboard_text(call: ServiceCall) -> None: + """Append text to the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_append(call.data[ATTR_TEXT]) + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +async def _async_clear_keyboard_text(call: ServiceCall) -> None: + """Clear text in the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_clear() + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Apple TV integration.""" + hass.services.async_register( + DOMAIN, + SERVICE_SET_KEYBOARD_TEXT, + _async_set_keyboard_text, + schema=SERVICE_SET_KEYBOARD_TEXT_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_APPEND_KEYBOARD_TEXT, + _async_append_keyboard_text, + schema=SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_KEYBOARD_TEXT, + _async_clear_keyboard_text, + schema=SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA, + ) diff --git a/homeassistant/components/apple_tv/services.yaml b/homeassistant/components/apple_tv/services.yaml new file mode 100644 index 00000000000..ce2914e4d0e --- /dev/null +++ b/homeassistant/components/apple_tv/services.yaml @@ -0,0 +1,31 @@ +set_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv + text: + required: true + selector: + text: + +append_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv + text: + required: true + selector: + text: + +clear_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index 98ff4b9acb7..c8da75fb1e2 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -69,6 +69,20 @@ } } }, + "exceptions": { + "keyboard_error": { + "message": "An error occurred while sending text to the Apple TV" + }, + "keyboard_not_available": { + "message": "Keyboard input is not supported by this device" + }, + "keyboard_not_focused": { + "message": "No text input field is currently focused on the Apple TV" + }, + "not_connected": { + "message": "Apple TV is not connected" + } + }, "options": { "step": { "init": { @@ -78,5 +92,45 @@ "description": "Configure general device settings" } } + }, + "services": { + "append_keyboard_text": { + "description": "Appends text to the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]" + }, + "text": { + "description": "The text to append.", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::text::name%]" + } + }, + "name": "Append keyboard text" + }, + "clear_keyboard_text": { + "description": "Clears the text in the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]" + } + }, + "name": "Clear keyboard text" + }, + "set_keyboard_text": { + "description": "Sets the text in the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "The Apple TV to send text to.", + "name": "Apple TV" + }, + "text": { + "description": "The text to set.", + "name": "Text" + } + }, + "name": "Set keyboard text" + } } } diff --git a/tests/components/apple_tv/test_services.py b/tests/components/apple_tv/test_services.py new file mode 100644 index 00000000000..d74383124bc --- /dev/null +++ b/tests/components/apple_tv/test_services.py @@ -0,0 +1,181 @@ +"""Tests for Apple TV keyboard services.""" + +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +from pyatv.const import DeviceModel, KeyboardFocusState, Protocol +from pyatv.exceptions import NotSupportedError, ProtocolError +import pytest + +from homeassistant.components.apple_tv.const import ATTR_TEXT, DOMAIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_ADDRESS, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .common import create_conf, mrp_service + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_atv() -> AsyncMock: + """Create a mock Apple TV interface with keyboard support.""" + atv = AsyncMock() + atv.keyboard = AsyncMock() + atv.keyboard.text_focus_state = KeyboardFocusState.Focused + atv.device_info.model = DeviceModel.Gen4K + atv.device_info.raw_model = "AppleTV6,2" + atv.device_info.version = "15.0" + atv.device_info.mac = "AA:BB:CC:DD:EE:FF" + return atv + + +@pytest.fixture +async def mock_config_entry( + hass: HomeAssistant, + mock_async_zeroconf: MagicMock, + mock_atv: AsyncMock, +) -> MockConfigEntry: + """Set up Apple TV integration with mocked pyatv.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Living Room", + unique_id="mrpid", + data={ + CONF_ADDRESS: "127.0.0.1", + CONF_NAME: "Living Room", + "credentials": {str(Protocol.MRP.value): "mrp_creds"}, + "identifiers": ["mrpid"], + }, + ) + entry.add_to_hass(hass) + + scan_result = create_conf("127.0.0.1", "Living Room", mrp_service()) + + with ( + patch("homeassistant.components.apple_tv.scan", return_value=[scan_result]), + patch("homeassistant.components.apple_tv.connect", return_value=mock_atv), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +async def test_set_keyboard_text( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test setting keyboard text.""" + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "Star Wars"}, + blocking=True, + ) + mock_atv.keyboard.text_set.assert_called_once_with("Star Wars") + + +async def test_append_keyboard_text( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test appending keyboard text.""" + await hass.services.async_call( + DOMAIN, + "append_keyboard_text", + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_TEXT: " Episode IV", + }, + blocking=True, + ) + mock_atv.keyboard.text_append.assert_called_once_with(" Episode IV") + + +async def test_clear_keyboard_text( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test clearing keyboard text.""" + await hass.services.async_call( + DOMAIN, + "clear_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id}, + blocking=True, + ) + mock_atv.keyboard.text_clear.assert_called_once() + + +async def test_set_keyboard_text_not_connected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test error when device is not connected.""" + mock_config_entry.runtime_data.atv = None + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "test"}, + blocking=True, + ) + + +async def test_set_keyboard_text_not_focused( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test error when keyboard is not focused.""" + mock_atv.keyboard.text_focus_state = KeyboardFocusState.Unfocused + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "test"}, + blocking=True, + ) + + +async def test_set_keyboard_text_not_supported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test error when keyboard is not supported by device.""" + with ( + patch.object( + type(mock_atv.keyboard), + "text_focus_state", + new_callable=PropertyMock, + side_effect=NotSupportedError("text_focus_state is not supported"), + create=True, + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "test"}, + blocking=True, + ) + + +async def test_set_keyboard_text_protocol_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test error when text_set raises a protocol error.""" + mock_atv.keyboard.text_set.side_effect = ProtocolError("send failed") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "test"}, + blocking=True, + )