Add new intents for cover, valve, vacuum, and media player (#110757)

* Add valve to HassTurnOn/Off

* Add set position for valves

* Add set position to covers

* Add HassTurnOn/Off for vacuums

* Add media player intents

* Split out vacuum intents

* Address comments

* Extra test
pull/108237/head
Michael Hansen 2024-02-19 21:28:42 -06:00 committed by GitHub
parent 015f9cdb35
commit ec4bd9a421
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 510 additions and 28 deletions

View File

@ -1,9 +1,16 @@
"""Intents for the cover integration."""
from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER
import voluptuous as vol
from homeassistant.const import (
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from . import DOMAIN
from . import ATTR_POSITION, DOMAIN
INTENT_OPEN_COVER = "HassOpenCover"
INTENT_CLOSE_COVER = "HassCloseCover"
@ -23,3 +30,12 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
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

@ -20,6 +20,11 @@ from homeassistant.components.lock import (
SERVICE_LOCK,
SERVICE_UNLOCK,
)
from homeassistant.components.valve import (
DOMAIN as VALVE_DOMAIN,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TOGGLE,
@ -82,10 +87,10 @@ class IntentPlatformProtocol(Protocol):
class OnOffIntentHandler(intent.ServiceIntentHandler):
"""Intent handler for on/off that handles covers too."""
"""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:
"""Call service on entity with special case for covers."""
"""Call service on entity with handling for special cases."""
hass = intent_obj.hass
if state.domain == COVER_DOMAIN:
@ -130,6 +135,27 @@ class OnOffIntentHandler(intent.ServiceIntentHandler):
)
return
if state.domain == VALVE_DOMAIN:
# on = opened
# off = closed
if self.service == SERVICE_TURN_ON:
service_name = SERVICE_OPEN_VALVE
else:
service_name = SERVICE_CLOSE_VALVE
await self._run_then_background(
hass.async_create_task(
hass.services.async_call(
VALVE_DOMAIN,
service_name,
{ATTR_ENTITY_ID: state.entity_id},
context=intent_obj.context,
blocking=True,
)
)
)
return
if not hass.services.has_service(state.domain, self.service):
raise intent.IntentHandleError(
f"Service {self.service} does not support entity {state.entity_id}"

View File

@ -0,0 +1,50 @@
"""Intents for the media_player integration."""
import voluptuous as vol
from homeassistant.const import (
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_VOLUME_SET,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from . import ATTR_MEDIA_VOLUME_LEVEL, DOMAIN
INTENT_MEDIA_PAUSE = "HassMediaPause"
INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
INTENT_MEDIA_NEXT = "HassMediaNext"
INTENT_SET_VOLUME = "HassSetVolume"
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the media_player intents."""
intent.async_register(
hass,
intent.ServiceIntentHandler(INTENT_MEDIA_UNPAUSE, DOMAIN, SERVICE_MEDIA_PLAY),
)
intent.async_register(
hass,
intent.ServiceIntentHandler(INTENT_MEDIA_PAUSE, DOMAIN, SERVICE_MEDIA_PAUSE),
)
intent.async_register(
hass,
intent.ServiceIntentHandler(
INTENT_MEDIA_NEXT, DOMAIN, SERVICE_MEDIA_NEXT_TRACK
),
)
intent.async_register(
hass,
intent.ServiceIntentHandler(
INTENT_SET_VOLUME,
DOMAIN,
SERVICE_VOLUME_SET,
extra_slots={
ATTR_MEDIA_VOLUME_LEVEL: vol.All(
vol.Range(min=0, max=100), lambda val: val / 100
)
},
),
)

View File

@ -0,0 +1,24 @@
"""Intents for the vacuum integration."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START
INTENT_VACUUM_START = "HassVacuumStart"
INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase"
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the vacuum intents."""
intent.async_register(
hass,
intent.ServiceIntentHandler(INTENT_VACUUM_START, DOMAIN, SERVICE_START),
)
intent.async_register(
hass,
intent.ServiceIntentHandler(
INTENT_VACUUM_RETURN_TO_BASE, DOMAIN, SERVICE_RETURN_TO_BASE
),
)

View File

@ -0,0 +1,22 @@
"""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

@ -7,6 +7,7 @@ from collections.abc import Collection, Coroutine, Iterable
import dataclasses
from dataclasses import dataclass
from enum import Enum
from functools import cached_property
import logging
from typing import Any, TypeVar
@ -33,6 +34,7 @@ INTENT_TURN_ON = "HassTurnOn"
INTENT_TOGGLE = "HassToggle"
INTENT_GET_STATE = "HassGetState"
INTENT_NEVERMIND = "HassNevermind"
INTENT_SET_POSITION = "HassSetPosition"
SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
@ -347,7 +349,6 @@ class IntentHandler:
intent_type: str | None = None
slot_schema: vol.Schema | None = None
_slot_schema: vol.Schema | None = None
platforms: Iterable[str] | None = []
@callback
@ -361,17 +362,20 @@ class IntentHandler:
if self.slot_schema is None:
return slots
if self._slot_schema is None:
self._slot_schema = vol.Schema(
{
key: SLOT_SCHEMA.extend({"value": validator})
for key, validator in self.slot_schema.items()
},
extra=vol.ALLOW_EXTRA,
)
return self._slot_schema(slots) # type: ignore[no-any-return]
@cached_property
def _slot_schema(self) -> vol.Schema:
"""Create validation schema for slots."""
assert self.slot_schema is not None
return vol.Schema(
{
key: SLOT_SCHEMA.extend({"value": validator})
for key, validator in self.slot_schema.items()
},
extra=vol.ALLOW_EXTRA,
)
async def async_handle(self, intent_obj: Intent) -> IntentResponse:
"""Handle the intent."""
raise NotImplementedError()
@ -398,13 +402,44 @@ class ServiceIntentHandler(IntentHandler):
service_timeout: float = 0.2
def __init__(
self, intent_type: str, domain: str, service: str, speech: str | None = None
self,
intent_type: str,
domain: str,
service: str,
speech: str | None = None,
extra_slots: dict[str, vol.Schema] | None = None,
) -> None:
"""Create Service Intent Handler."""
self.intent_type = intent_type
self.domain = domain
self.service = service
self.speech = speech
self.extra_slots = extra_slots
@cached_property
def _slot_schema(self) -> vol.Schema:
"""Create validation schema for slots (with extra required slots)."""
if self.slot_schema is None:
raise ValueError("Slot schema is not defined")
if self.extra_slots:
slot_schema = {
**self.slot_schema,
**{
vol.Required(key): schema
for key, schema in self.extra_slots.items()
},
}
else:
slot_schema = self.slot_schema
return vol.Schema(
{
key: SLOT_SCHEMA.extend({"value": validator})
for key, validator in slot_schema.items()
},
extra=vol.ALLOW_EXTRA,
)
async def async_handle(self, intent_obj: Intent) -> IntentResponse:
"""Handle the hass intent."""
@ -467,6 +502,9 @@ class ServiceIntentHandler(IntentHandler):
area=area_name or area_id,
)
# Update intent slots to include any transformations done by the schemas
intent_obj.slots = slots
response = await self.async_handle_states(intent_obj, states, area)
# Make the matched states available in the response
@ -539,12 +577,19 @@ class ServiceIntentHandler(IntentHandler):
async def async_call_service(self, intent_obj: Intent, state: State) -> None:
"""Call service on entity."""
hass = intent_obj.hass
service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id}
if self.extra_slots:
service_data.update(
{key: intent_obj.slots[key]["value"] for key in self.extra_slots}
)
await self._run_then_background(
hass.async_create_task(
hass.services.async_call(
self.domain,
self.service,
{ATTR_ENTITY_ID: state.entity_id},
service_data,
context=intent_obj.context,
blocking=True,
),

View File

@ -1,9 +1,14 @@
"""The tests for the cover platform."""
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
DOMAIN,
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
intent as cover_intent,
)
from homeassistant.const import STATE_CLOSED, STATE_OPEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
@ -14,37 +19,66 @@ async def test_open_cover_intent(hass: HomeAssistant) -> None:
"""Test HassOpenCover intent."""
await cover_intent.async_setup_intents(hass)
hass.states.async_set("cover.garage_door", "closed")
calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
hass.states.async_set(f"{DOMAIN}.garage_door", STATE_CLOSED)
calls = async_mock_service(hass, DOMAIN, SERVICE_OPEN_COVER)
response = await intent.async_handle(
hass, "test", "HassOpenCover", {"name": {"value": "garage door"}}
hass, "test", cover_intent.INTENT_OPEN_COVER, {"name": {"value": "garage door"}}
)
await hass.async_block_till_done()
assert response.speech["plain"]["speech"] == "Opened garage door"
assert len(calls) == 1
call = calls[0]
assert call.domain == "cover"
assert call.service == "open_cover"
assert call.data == {"entity_id": "cover.garage_door"}
assert call.domain == DOMAIN
assert call.service == SERVICE_OPEN_COVER
assert call.data == {"entity_id": f"{DOMAIN}.garage_door"}
async def test_close_cover_intent(hass: HomeAssistant) -> None:
"""Test HassCloseCover intent."""
await cover_intent.async_setup_intents(hass)
hass.states.async_set("cover.garage_door", "open")
calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER)
hass.states.async_set(f"{DOMAIN}.garage_door", STATE_OPEN)
calls = async_mock_service(hass, DOMAIN, SERVICE_CLOSE_COVER)
response = await intent.async_handle(
hass, "test", "HassCloseCover", {"name": {"value": "garage door"}}
hass,
"test",
cover_intent.INTENT_CLOSE_COVER,
{"name": {"value": "garage door"}},
)
await hass.async_block_till_done()
assert response.speech["plain"]["speech"] == "Closed garage door"
assert len(calls) == 1
call = calls[0]
assert call.domain == "cover"
assert call.service == "close_cover"
assert call.data == {"entity_id": "cover.garage_door"}
assert call.domain == DOMAIN
assert call.service == SERVICE_CLOSE_COVER
assert call.data == {"entity_id": f"{DOMAIN}.garage_door"}
async def test_set_cover_position(hass: HomeAssistant) -> None:
"""Test HassSetPosition intent for covers."""
await cover_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_cover"
hass.states.async_set(
entity_id, STATE_CLOSED, attributes={ATTR_CURRENT_POSITION: 0}
)
calls = async_mock_service(hass, DOMAIN, SERVICE_SET_COVER_POSITION)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_SET_POSITION,
{"name": {"value": "test cover"}, "position": {"value": 50}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_SET_COVER_POSITION
assert call.data == {"entity_id": entity_id, "position": 50}

View File

@ -0,0 +1,111 @@
"""The tests for the media_player platform."""
from homeassistant.components.media_player import (
DOMAIN,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_VOLUME_SET,
intent as media_player_intent,
)
from homeassistant.const import STATE_IDLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from tests.common import async_mock_service
async def test_pause_media_player_intent(hass: HomeAssistant) -> None:
"""Test HassMediaPause intent for media players."""
await media_player_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_media_player"
hass.states.async_set(entity_id, STATE_IDLE)
calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE)
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_MEDIA_PAUSE,
{"name": {"value": "test media player"}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_MEDIA_PAUSE
assert call.data == {"entity_id": entity_id}
async def test_unpause_media_player_intent(hass: HomeAssistant) -> None:
"""Test HassMediaUnpause intent for media players."""
await media_player_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_media_player"
hass.states.async_set(entity_id, STATE_IDLE)
calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY)
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_MEDIA_UNPAUSE,
{"name": {"value": "test media player"}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_MEDIA_PLAY
assert call.data == {"entity_id": entity_id}
async def test_next_media_player_intent(hass: HomeAssistant) -> None:
"""Test HassMediaNext intent for media players."""
await media_player_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_media_player"
hass.states.async_set(entity_id, STATE_IDLE)
calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_NEXT_TRACK)
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_MEDIA_NEXT,
{"name": {"value": "test media player"}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_MEDIA_NEXT_TRACK
assert call.data == {"entity_id": entity_id}
async def test_volume_media_player_intent(hass: HomeAssistant) -> None:
"""Test HassSetVolume intent for media players."""
await media_player_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_media_player"
hass.states.async_set(entity_id, STATE_IDLE)
calls = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_SET)
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_SET_VOLUME,
{"name": {"value": "test media player"}, "volume_level": {"value": 50}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_VOLUME_SET
assert call.data == {"entity_id": entity_id, "volume_level": 0.5}

View File

@ -0,0 +1,61 @@
"""The tests for the vacuum platform."""
from homeassistant.components.vacuum import (
DOMAIN,
SERVICE_RETURN_TO_BASE,
SERVICE_START,
intent as vacuum_intent,
)
from homeassistant.const import STATE_IDLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from tests.common import async_mock_service
async def test_start_vacuum_intent(hass: HomeAssistant) -> None:
"""Test HassTurnOn intent for vacuums."""
await vacuum_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_vacuum"
hass.states.async_set(entity_id, STATE_IDLE)
calls = async_mock_service(hass, DOMAIN, SERVICE_START)
response = await intent.async_handle(
hass,
"test",
vacuum_intent.INTENT_VACUUM_START,
{"name": {"value": "test vacuum"}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_START
assert call.data == {"entity_id": entity_id}
async def test_stop_vacuum_intent(hass: HomeAssistant) -> None:
"""Test HassTurnOff intent for vacuums."""
await vacuum_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_vacuum"
hass.states.async_set(entity_id, STATE_IDLE)
calls = async_mock_service(hass, DOMAIN, SERVICE_RETURN_TO_BASE)
response = await intent.async_handle(
hass,
"test",
vacuum_intent.INTENT_VACUUM_RETURN_TO_BASE,
{"name": {"value": "test vacuum"}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_RETURN_TO_BASE
assert call.data == {"entity_id": entity_id}

View File

@ -0,0 +1,84 @@
"""The tests for the valve platform."""
from homeassistant.components.valve import (
ATTR_CURRENT_POSITION,
DOMAIN,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
intent as valve_intent,
)
from homeassistant.const import STATE_CLOSED, STATE_OPEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from homeassistant.setup import async_setup_component
from tests.common import async_mock_service
async def test_open_valve_intent(hass: HomeAssistant) -> None:
"""Test HassTurnOn intent for valves."""
assert await async_setup_component(hass, "intent", {})
entity_id = f"{DOMAIN}.test_valve"
hass.states.async_set(entity_id, STATE_CLOSED)
calls = async_mock_service(hass, DOMAIN, SERVICE_OPEN_VALVE)
response = await intent.async_handle(
hass, "test", intent.INTENT_TURN_ON, {"name": {"value": "test valve"}}
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_OPEN_VALVE
assert call.data == {"entity_id": entity_id}
async def test_close_valve_intent(hass: HomeAssistant) -> None:
"""Test HassTurnOff intent for valves."""
assert await async_setup_component(hass, "intent", {})
entity_id = f"{DOMAIN}.test_valve"
hass.states.async_set(entity_id, STATE_OPEN)
calls = async_mock_service(hass, DOMAIN, SERVICE_CLOSE_VALVE)
response = await intent.async_handle(
hass, "test", intent.INTENT_TURN_OFF, {"name": {"value": "test valve"}}
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_CLOSE_VALVE
assert call.data == {"entity_id": entity_id}
async def test_set_valve_position(hass: HomeAssistant) -> None:
"""Test HassSetPosition intent for valves."""
await valve_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_valve"
hass.states.async_set(
entity_id, STATE_CLOSED, attributes={ATTR_CURRENT_POSITION: 0}
)
calls = async_mock_service(hass, DOMAIN, SERVICE_SET_VALVE_POSITION)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_SET_POSITION,
{"name": {"value": "test valve"}, "position": {"value": 50}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
call = calls[0]
assert call.domain == DOMAIN
assert call.service == SERVICE_SET_VALVE_POSITION
assert call.data == {"entity_id": entity_id, "position": 50}

View File

@ -1,4 +1,5 @@
"""Tests for the intent helpers."""
import asyncio
from unittest.mock import MagicMock, patch
@ -176,6 +177,14 @@ def test_async_validate_slots() -> None:
)
def test_async_validate_slots_no_schema() -> None:
"""Test async_validate_slots of IntentHandler with no schema."""
handler1 = MockIntentHandler(None)
assert handler1.async_validate_slots({"name": {"value": "kitchen"}}) == {
"name": {"value": "kitchen"}
}
async def test_cant_turn_on_lock(hass: HomeAssistant) -> None:
"""Test that we can't turn on entities that don't support it."""
assert await async_setup_component(hass, "homeassistant", {})