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:
committed by
GitHub
parent
49d63892d1
commit
5197722733
@@ -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)
|
||||
|
||||
@@ -9,3 +9,5 @@ CONF_START_OFF = "start_off"
|
||||
|
||||
SIGNAL_CONNECTED = "apple_tv_connected"
|
||||
SIGNAL_DISCONNECTED = "apple_tv_disconnected"
|
||||
|
||||
ATTR_TEXT = "text"
|
||||
|
||||
@@ -8,5 +8,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"append_keyboard_text": {
|
||||
"service": "mdi:keyboard"
|
||||
},
|
||||
"clear_keyboard_text": {
|
||||
"service": "mdi:keyboard-off"
|
||||
},
|
||||
"set_keyboard_text": {
|
||||
"service": "mdi:keyboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
homeassistant/components/apple_tv/services.py
Normal file
130
homeassistant/components/apple_tv/services.py
Normal 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,
|
||||
)
|
||||
31
homeassistant/components/apple_tv/services.yaml
Normal file
31
homeassistant/components/apple_tv/services.yaml
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
181
tests/components/apple_tv/test_services.py
Normal file
181
tests/components/apple_tv/test_services.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user