1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add keyboard text input services to Apple TV integration (#165638)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Keith Roehrenbeck
2026-04-01 07:05:20 -05:00
committed by GitHub
parent 49d63892d1
commit 5197722733
7 changed files with 420 additions and 1 deletions

View File

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

View File

@@ -9,3 +9,5 @@ CONF_START_OFF = "start_off"
SIGNAL_CONNECTED = "apple_tv_connected"
SIGNAL_DISCONNECTED = "apple_tv_disconnected"
ATTR_TEXT = "text"

View File

@@ -8,5 +8,16 @@
}
}
}
},
"services": {
"append_keyboard_text": {
"service": "mdi:keyboard"
},
"clear_keyboard_text": {
"service": "mdi:keyboard-off"
},
"set_keyboard_text": {
"service": "mdi:keyboard"
}
}
}

View File

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

View File

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

View File

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

View File

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