Move HassSetPosition to homeassistant domain (#111867)

* Move HassSetPosition to homeassistant domain

* Add test for unsupported domain with HassSetPosition

* Split service intent handler

* cleanup

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Michael Hansen 2024-02-29 20:53:52 -06:00 committed by GitHub
parent 5890a7d38c
commit 0d0b64d351
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 105 additions and 63 deletions

View file

@ -1,16 +1,11 @@
"""Intents for the cover integration.""" """Intents for the cover integration."""
import voluptuous as vol
from homeassistant.const import ( from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent from homeassistant.helpers import intent
from . import ATTR_POSITION, DOMAIN from . import DOMAIN
INTENT_OPEN_COVER = "HassOpenCover" INTENT_OPEN_COVER = "HassOpenCover"
INTENT_CLOSE_COVER = "HassCloseCover" INTENT_CLOSE_COVER = "HassCloseCover"
@ -30,12 +25,3 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
INTENT_CLOSE_COVER, DOMAIN, SERVICE_CLOSE_COVER, "Closed {}" INTENT_CLOSE_COVER, DOMAIN, SERVICE_CLOSE_COVER, "Closed {}"
), ),
) )
intent.async_register(
hass,
intent.ServiceIntentHandler(
intent.INTENT_SET_POSITION,
DOMAIN,
SERVICE_SET_COVER_POSITION,
extra_slots={ATTR_POSITION: vol.All(vol.Range(min=0, max=100))},
),
)

View file

@ -10,9 +10,11 @@ import voluptuous as vol
from homeassistant.components import http from homeassistant.components import http
from homeassistant.components.cover import ( from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN as COVER_DOMAIN, DOMAIN as COVER_DOMAIN,
SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER, SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
) )
from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.lock import ( from homeassistant.components.lock import (
@ -24,6 +26,7 @@ from homeassistant.components.valve import (
DOMAIN as VALVE_DOMAIN, DOMAIN as VALVE_DOMAIN,
SERVICE_CLOSE_VALVE, SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE, SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
) )
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
@ -75,6 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass, hass,
NevermindIntentHandler(), NevermindIntentHandler(),
) )
intent.async_register(hass, SetPositionIntentHandler())
return True return True
@ -89,14 +93,16 @@ class IntentPlatformProtocol(Protocol):
class OnOffIntentHandler(intent.ServiceIntentHandler): class OnOffIntentHandler(intent.ServiceIntentHandler):
"""Intent handler for on/off that also supports covers, valves, locks, etc.""" """Intent handler for on/off that also supports covers, valves, locks, etc."""
async def async_call_service(self, intent_obj: intent.Intent, state: State) -> None: async def async_call_service(
self, domain: str, service: str, intent_obj: intent.Intent, state: State
) -> None:
"""Call service on entity with handling for special cases.""" """Call service on entity with handling for special cases."""
hass = intent_obj.hass hass = intent_obj.hass
if state.domain == COVER_DOMAIN: if state.domain == COVER_DOMAIN:
# on = open # on = open
# off = close # off = close
if self.service == SERVICE_TURN_ON: if service == SERVICE_TURN_ON:
service_name = SERVICE_OPEN_COVER service_name = SERVICE_OPEN_COVER
else: else:
service_name = SERVICE_CLOSE_COVER service_name = SERVICE_CLOSE_COVER
@ -117,7 +123,7 @@ class OnOffIntentHandler(intent.ServiceIntentHandler):
if state.domain == LOCK_DOMAIN: if state.domain == LOCK_DOMAIN:
# on = lock # on = lock
# off = unlock # off = unlock
if self.service == SERVICE_TURN_ON: if service == SERVICE_TURN_ON:
service_name = SERVICE_LOCK service_name = SERVICE_LOCK
else: else:
service_name = SERVICE_UNLOCK service_name = SERVICE_UNLOCK
@ -138,7 +144,7 @@ class OnOffIntentHandler(intent.ServiceIntentHandler):
if state.domain == VALVE_DOMAIN: if state.domain == VALVE_DOMAIN:
# on = opened # on = opened
# off = closed # off = closed
if self.service == SERVICE_TURN_ON: if service == SERVICE_TURN_ON:
service_name = SERVICE_OPEN_VALVE service_name = SERVICE_OPEN_VALVE
else: else:
service_name = SERVICE_CLOSE_VALVE service_name = SERVICE_CLOSE_VALVE
@ -156,13 +162,13 @@ class OnOffIntentHandler(intent.ServiceIntentHandler):
) )
return return
if not hass.services.has_service(state.domain, self.service): if not hass.services.has_service(state.domain, service):
raise intent.IntentHandleError( raise intent.IntentHandleError(
f"Service {self.service} does not support entity {state.entity_id}" f"Service {service} does not support entity {state.entity_id}"
) )
# Fall back to homeassistant.turn_on/off # Fall back to homeassistant.turn_on/off
await super().async_call_service(intent_obj, state) await super().async_call_service(domain, service, intent_obj, state)
class GetStateIntentHandler(intent.IntentHandler): class GetStateIntentHandler(intent.IntentHandler):
@ -296,6 +302,29 @@ class NevermindIntentHandler(intent.IntentHandler):
return intent_obj.create_response() return intent_obj.create_response()
class SetPositionIntentHandler(intent.DynamicServiceIntentHandler):
"""Intent handler for setting positions."""
def __init__(self) -> None:
"""Create set position handler."""
super().__init__(
intent.INTENT_SET_POSITION,
extra_slots={ATTR_POSITION: vol.All(vol.Range(min=0, max=100))},
)
def get_domain_and_service(
self, intent_obj: intent.Intent, state: State
) -> tuple[str, str]:
"""Get the domain and service name to call."""
if state.domain == COVER_DOMAIN:
return (COVER_DOMAIN, SERVICE_SET_COVER_POSITION)
if state.domain == VALVE_DOMAIN:
return (VALVE_DOMAIN, SERVICE_SET_VALVE_POSITION)
raise intent.IntentHandleError(f"Domain not supported: {state.domain}")
async def _async_process_intent( async def _async_process_intent(
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
) -> None: ) -> None:

View file

@ -1,22 +0,0 @@
"""Intents for the valve integration."""
import voluptuous as vol
from homeassistant.const import SERVICE_SET_VALVE_POSITION
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from . import ATTR_POSITION, DOMAIN
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the valve intents."""
intent.async_register(
hass,
intent.ServiceIntentHandler(
intent.INTENT_SET_POSITION,
DOMAIN,
SERVICE_SET_VALVE_POSITION,
extra_slots={ATTR_POSITION: vol.All(vol.Range(min=0, max=100))},
),
)

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from abc import abstractmethod
import asyncio import asyncio
from collections.abc import Collection, Coroutine, Iterable from collections.abc import Collection, Coroutine, Iterable
import dataclasses import dataclasses
@ -385,8 +386,8 @@ class IntentHandler:
return f"<{self.__class__.__name__} - {self.intent_type}>" return f"<{self.__class__.__name__} - {self.intent_type}>"
class ServiceIntentHandler(IntentHandler): class DynamicServiceIntentHandler(IntentHandler):
"""Service Intent handler registration. """Service Intent handler registration (dynamic).
Service specific intent handler that calls a service by name/entity_id. Service specific intent handler that calls a service by name/entity_id.
""" """
@ -404,15 +405,11 @@ class ServiceIntentHandler(IntentHandler):
def __init__( def __init__(
self, self,
intent_type: str, intent_type: str,
domain: str,
service: str,
speech: str | None = None, speech: str | None = None,
extra_slots: dict[str, vol.Schema] | None = None, extra_slots: dict[str, vol.Schema] | None = None,
) -> None: ) -> None:
"""Create Service Intent Handler.""" """Create Service Intent Handler."""
self.intent_type = intent_type self.intent_type = intent_type
self.domain = domain
self.service = service
self.speech = speech self.speech = speech
self.extra_slots = extra_slots self.extra_slots = extra_slots
@ -441,6 +438,13 @@ class ServiceIntentHandler(IntentHandler):
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
@abstractmethod
def get_domain_and_service(
self, intent_obj: Intent, state: State
) -> tuple[str, str]:
"""Get the domain and service name to call."""
raise NotImplementedError()
async def async_handle(self, intent_obj: Intent) -> IntentResponse: async def async_handle(self, intent_obj: Intent) -> IntentResponse:
"""Handle the hass intent.""" """Handle the hass intent."""
hass = intent_obj.hass hass = intent_obj.hass
@ -536,7 +540,10 @@ class ServiceIntentHandler(IntentHandler):
service_coros: list[Coroutine[Any, Any, None]] = [] service_coros: list[Coroutine[Any, Any, None]] = []
for state in states: for state in states:
service_coros.append(self.async_call_service(intent_obj, state)) domain, service = self.get_domain_and_service(intent_obj, state)
service_coros.append(
self.async_call_service(domain, service, intent_obj, state)
)
# Handle service calls in parallel, noting failures as they occur. # Handle service calls in parallel, noting failures as they occur.
failed_results: list[IntentResponseTarget] = [] failed_results: list[IntentResponseTarget] = []
@ -558,7 +565,7 @@ class ServiceIntentHandler(IntentHandler):
# If no entities succeeded, raise an error. # If no entities succeeded, raise an error.
failed_entity_ids = [target.id for target in failed_results] failed_entity_ids = [target.id for target in failed_results]
raise IntentHandleError( raise IntentHandleError(
f"Failed to call {self.service} for: {failed_entity_ids}" f"Failed to call {service} for: {failed_entity_ids}"
) )
response.async_set_results( response.async_set_results(
@ -574,7 +581,9 @@ class ServiceIntentHandler(IntentHandler):
return response return response
async def async_call_service(self, intent_obj: Intent, state: State) -> None: async def async_call_service(
self, domain: str, service: str, intent_obj: Intent, state: State
) -> None:
"""Call service on entity.""" """Call service on entity."""
hass = intent_obj.hass hass = intent_obj.hass
@ -587,13 +596,13 @@ class ServiceIntentHandler(IntentHandler):
await self._run_then_background( await self._run_then_background(
hass.async_create_task( hass.async_create_task(
hass.services.async_call( hass.services.async_call(
self.domain, domain,
self.service, service,
service_data, service_data,
context=intent_obj.context, context=intent_obj.context,
blocking=True, blocking=True,
), ),
f"intent_call_service_{self.domain}_{self.service}", f"intent_call_service_{domain}_{service}",
) )
) )
@ -615,6 +624,32 @@ class ServiceIntentHandler(IntentHandler):
raise raise
class ServiceIntentHandler(DynamicServiceIntentHandler):
"""Service Intent handler registration.
Service specific intent handler that calls a service by name/entity_id.
"""
def __init__(
self,
intent_type: str,
domain: str,
service: str,
speech: str | None = None,
extra_slots: dict[str, vol.Schema] | None = None,
) -> None:
"""Create service handler."""
super().__init__(intent_type, speech=speech, extra_slots=extra_slots)
self.domain = domain
self.service = service
def get_domain_and_service(
self, intent_obj: Intent, state: State
) -> tuple[str, str]:
"""Get the domain and service name to call."""
return (self.domain, self.service)
class IntentCategory(Enum): class IntentCategory(Enum):
"""Category of an intent.""" """Category of an intent."""

View file

@ -8,7 +8,6 @@ from homeassistant.components.cover import intent as cover_intent
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.media_player import intent as media_player_intent from homeassistant.components.media_player import intent as media_player_intent
from homeassistant.components.vacuum import intent as vaccum_intent from homeassistant.components.vacuum import intent as vaccum_intent
from homeassistant.components.valve import intent as valve_intent
from homeassistant.const import STATE_CLOSED from homeassistant.const import STATE_CLOSED
from homeassistant.core import Context, HomeAssistant from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import intent from homeassistant.helpers import intent
@ -84,8 +83,6 @@ async def test_valve_intents(
init_components, init_components,
) -> None: ) -> None:
"""Test open/close/set position for valves.""" """Test open/close/set position for valves."""
await valve_intent.async_setup_intents(hass)
entity_id = f"{valve.DOMAIN}.main_valve" entity_id = f"{valve.DOMAIN}.main_valve"
hass.states.async_set(entity_id, STATE_CLOSED) hass.states.async_set(entity_id, STATE_CLOSED)
async_expose_entity(hass, conversation.DOMAIN, entity_id, True) async_expose_entity(hass, conversation.DOMAIN, entity_id, True)

View file

@ -11,6 +11,7 @@ from homeassistant.components.cover import (
from homeassistant.const import STATE_CLOSED, STATE_OPEN from homeassistant.const import STATE_CLOSED, STATE_OPEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent from homeassistant.helpers import intent
from homeassistant.setup import async_setup_component
from tests.common import async_mock_service from tests.common import async_mock_service
@ -60,7 +61,7 @@ async def test_close_cover_intent(hass: HomeAssistant) -> None:
async def test_set_cover_position(hass: HomeAssistant) -> None: async def test_set_cover_position(hass: HomeAssistant) -> None:
"""Test HassSetPosition intent for covers.""" """Test HassSetPosition intent for covers."""
await cover_intent.async_setup_intents(hass) assert await async_setup_component(hass, "intent", {})
entity_id = f"{DOMAIN}.test_cover" entity_id = f"{DOMAIN}.test_cover"
hass.states.async_set( hass.states.async_set(

View file

@ -432,3 +432,20 @@ async def test_get_state_intent(
"domain": {"value": "light"}, "domain": {"value": "light"},
}, },
) )
async def test_set_position_intent_unsupported_domain(hass: HomeAssistant) -> None:
"""Test that HassSetPosition intent fails with unsupported domain."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
# Can't set position of lights
hass.states.async_set("light.test_light", "off")
with pytest.raises(intent.IntentHandleError):
await intent.async_handle(
hass,
"test",
"HassSetPosition",
{"name": {"value": "test light"}, "position": {"value": 100}},
)

View file

@ -6,7 +6,6 @@ from homeassistant.components.valve import (
SERVICE_CLOSE_VALVE, SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE, SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION, SERVICE_SET_VALVE_POSITION,
intent as valve_intent,
) )
from homeassistant.const import STATE_CLOSED, STATE_OPEN from homeassistant.const import STATE_CLOSED, STATE_OPEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -60,7 +59,7 @@ async def test_close_valve_intent(hass: HomeAssistant) -> None:
async def test_set_valve_position(hass: HomeAssistant) -> None: async def test_set_valve_position(hass: HomeAssistant) -> None:
"""Test HassSetPosition intent for valves.""" """Test HassSetPosition intent for valves."""
await valve_intent.async_setup_intents(hass) assert await async_setup_component(hass, "intent", {})
entity_id = f"{DOMAIN}.test_valve" entity_id = f"{DOMAIN}.test_valve"
hass.states.async_set( hass.states.async_set(