Allow timer management from any device (#120440)

This commit is contained in:
Michael Hansen 2024-06-26 02:06:56 -05:00 committed by GitHub
parent 8ce53d28e7
commit d3ceaef098
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 77 additions and 109 deletions

View file

@ -490,7 +490,7 @@ class FindTimerFilter(StrEnum):
def _find_timer( def _find_timer(
hass: HomeAssistant, hass: HomeAssistant,
device_id: str, device_id: str | None,
slots: dict[str, Any], slots: dict[str, Any],
find_filter: FindTimerFilter | None = None, find_filter: FindTimerFilter | None = None,
) -> TimerInfo: ) -> TimerInfo:
@ -577,7 +577,7 @@ def _find_timer(
return matching_timers[0] return matching_timers[0]
# Use device id # Use device id
if matching_timers: if matching_timers and device_id:
matching_device_timers = [ matching_device_timers = [
t for t in matching_timers if (t.device_id == device_id) t for t in matching_timers if (t.device_id == device_id)
] ]
@ -626,7 +626,7 @@ def _find_timer(
def _find_timers( def _find_timers(
hass: HomeAssistant, device_id: str, slots: dict[str, Any] hass: HomeAssistant, device_id: str | None, slots: dict[str, Any]
) -> list[TimerInfo]: ) -> list[TimerInfo]:
"""Match multiple timers with constraints or raise an error.""" """Match multiple timers with constraints or raise an error."""
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
@ -689,6 +689,10 @@ def _find_timers(
# No matches # No matches
return matching_timers return matching_timers
if not device_id:
# Can't order using area/floor
return matching_timers
# Use device id to order remaining timers # Use device id to order remaining timers
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id) device = device_registry.async_get(device_id)
@ -861,12 +865,6 @@ class CancelTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
if not (
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
):
# Fail early
raise TimersNotSupportedError(intent_obj.device_id)
timer = _find_timer(hass, intent_obj.device_id, slots) timer = _find_timer(hass, intent_obj.device_id, slots)
timer_manager.cancel_timer(timer.id) timer_manager.cancel_timer(timer.id)
return intent_obj.create_response() return intent_obj.create_response()
@ -890,12 +888,6 @@ class IncreaseTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
if not (
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
):
# Fail early
raise TimersNotSupportedError(intent_obj.device_id)
total_seconds = _get_total_seconds(slots) total_seconds = _get_total_seconds(slots)
timer = _find_timer(hass, intent_obj.device_id, slots) timer = _find_timer(hass, intent_obj.device_id, slots)
timer_manager.add_time(timer.id, total_seconds) timer_manager.add_time(timer.id, total_seconds)
@ -920,12 +912,6 @@ class DecreaseTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
if not (
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
):
# Fail early
raise TimersNotSupportedError(intent_obj.device_id)
total_seconds = _get_total_seconds(slots) total_seconds = _get_total_seconds(slots)
timer = _find_timer(hass, intent_obj.device_id, slots) timer = _find_timer(hass, intent_obj.device_id, slots)
timer_manager.remove_time(timer.id, total_seconds) timer_manager.remove_time(timer.id, total_seconds)
@ -949,12 +935,6 @@ class PauseTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
if not (
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
):
# Fail early
raise TimersNotSupportedError(intent_obj.device_id)
timer = _find_timer( timer = _find_timer(
hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_ACTIVE hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_ACTIVE
) )
@ -979,12 +959,6 @@ class UnpauseTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
if not (
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
):
# Fail early
raise TimersNotSupportedError(intent_obj.device_id)
timer = _find_timer( timer = _find_timer(
hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_INACTIVE hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_INACTIVE
) )
@ -1006,15 +980,8 @@ class TimerStatusIntentHandler(intent.IntentHandler):
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent.""" """Handle the intent."""
hass = intent_obj.hass hass = intent_obj.hass
timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
if not (
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
):
# Fail early
raise TimersNotSupportedError(intent_obj.device_id)
statuses: list[dict[str, Any]] = [] statuses: list[dict[str, Any]] = []
for timer in _find_timers(hass, intent_obj.device_id, slots): for timer in _find_timers(hass, intent_obj.device_id, slots):
total_seconds = timer.seconds_left total_seconds = timer.seconds_left

View file

@ -355,7 +355,7 @@ class AssistAPI(API):
if not llm_context.device_id or not async_device_supports_timers( if not llm_context.device_id or not async_device_supports_timers(
self.hass, llm_context.device_id self.hass, llm_context.device_id
): ):
prompt.append("This device does not support timers.") prompt.append("This device is not able to start timers.")
if exposed_entities: if exposed_entities:
prompt.append( prompt.append(

View file

@ -860,13 +860,27 @@ async def test_error_feature_not_supported(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("init_components") @pytest.mark.usefixtures("init_components")
async def test_error_no_timer_support(hass: HomeAssistant) -> None: async def test_error_no_timer_support(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test error message when a device does not support timers (no handler is registered).""" """Test error message when a device does not support timers (no handler is registered)."""
device_id = "test_device" area_kitchen = area_registry.async_create("kitchen")
entry = MockConfigEntry()
entry.add_to_hass(hass)
device_kitchen = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=set(),
identifiers={("demo", "device-kitchen")},
)
device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id)
device_id = device_kitchen.id
# No timer handler is registered for the device # No timer handler is registered for the device
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "pause timer", None, Context(), None, device_id=device_id hass, "set a 5 minute timer", None, Context(), None, device_id=device_id
) )
assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.response_type == intent.IntentResponseType.ERROR

View file

@ -64,6 +64,7 @@ async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None:
async_register_timer_handler(hass, device_id, handle_timer) async_register_timer_handler(hass, device_id, handle_timer)
# A device that has been registered to handle timers is required
result = await intent.async_handle( result = await intent.async_handle(
hass, hass,
"test", "test",
@ -185,6 +186,27 @@ async def test_cancel_timer(hass: HomeAssistant, init_components) -> None:
async with asyncio.timeout(1): async with asyncio.timeout(1):
await cancelled_event.wait() await cancelled_event.wait()
# Cancel without a device
timer_name = None
started_event.clear()
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"hours": {"value": 1},
"minutes": {"value": 2},
"seconds": {"value": 3},
},
device_id=device_id,
)
async with asyncio.timeout(1):
await started_event.wait()
result = await intent.async_handle(hass, "test", intent.INTENT_CANCEL_TIMER, {})
assert result.response_type == intent.IntentResponseType.ACTION_DONE
async def test_increase_timer(hass: HomeAssistant, init_components) -> None: async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
"""Test increasing the time of a running timer.""" """Test increasing the time of a running timer."""
@ -260,7 +282,6 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
"minutes": {"value": 0}, "minutes": {"value": 0},
"seconds": {"value": 0}, "seconds": {"value": 0},
}, },
device_id=device_id,
) )
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
@ -279,7 +300,6 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
"minutes": {"value": 5}, "minutes": {"value": 5},
"seconds": {"value": 30}, "seconds": {"value": 30},
}, },
device_id=device_id,
) )
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
@ -293,7 +313,6 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
"test", "test",
intent.INTENT_CANCEL_TIMER, intent.INTENT_CANCEL_TIMER,
{"name": {"value": timer_name}}, {"name": {"value": timer_name}},
device_id=device_id,
) )
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
@ -375,7 +394,6 @@ async def test_decrease_timer(hass: HomeAssistant, init_components) -> None:
"start_seconds": {"value": 3}, "start_seconds": {"value": 3},
"seconds": {"value": 30}, "seconds": {"value": 30},
}, },
device_id=device_id,
) )
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
@ -389,7 +407,6 @@ async def test_decrease_timer(hass: HomeAssistant, init_components) -> None:
"test", "test",
intent.INTENT_CANCEL_TIMER, intent.INTENT_CANCEL_TIMER,
{"name": {"value": timer_name}}, {"name": {"value": timer_name}},
device_id=device_id,
) )
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
@ -467,7 +484,6 @@ async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) -
"start_seconds": {"value": 3}, "start_seconds": {"value": 3},
"seconds": {"value": original_total_seconds + 1}, "seconds": {"value": original_total_seconds + 1},
}, },
device_id=device_id,
) )
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
@ -482,43 +498,25 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None:
"""Test finding a timer with the wrong info.""" """Test finding a timer with the wrong info."""
device_id = "test_device" device_id = "test_device"
for intent_name in ( # No device id
intent.INTENT_START_TIMER, with pytest.raises(TimersNotSupportedError):
intent.INTENT_CANCEL_TIMER, await intent.async_handle(
intent.INTENT_PAUSE_TIMER, hass,
intent.INTENT_UNPAUSE_TIMER, "test",
intent.INTENT_INCREASE_TIMER,
intent.INTENT_DECREASE_TIMER,
intent.INTENT_TIMER_STATUS,
):
if intent_name in (
intent.INTENT_START_TIMER, intent.INTENT_START_TIMER,
intent.INTENT_INCREASE_TIMER, {"minutes": {"value": 5}},
intent.INTENT_DECREASE_TIMER, device_id=None,
): )
slots = {"minutes": {"value": 5}}
else:
slots = {}
# No device id # Unregistered device
with pytest.raises(TimersNotSupportedError): with pytest.raises(TimersNotSupportedError):
await intent.async_handle( await intent.async_handle(
hass, hass,
"test", "test",
intent_name, intent.INTENT_START_TIMER,
slots, {"minutes": {"value": 5}},
device_id=None, device_id=device_id,
) )
# Unregistered device
with pytest.raises(TimersNotSupportedError):
await intent.async_handle(
hass,
"test",
intent_name,
slots,
device_id=device_id,
)
# Must register a handler before we can do anything with timers # Must register a handler before we can do anything with timers
@callback @callback
@ -543,7 +541,6 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None:
"test", "test",
intent.INTENT_INCREASE_TIMER, intent.INTENT_INCREASE_TIMER,
{"name": {"value": "PIZZA "}, "minutes": {"value": 1}}, {"name": {"value": "PIZZA "}, "minutes": {"value": 1}},
device_id=device_id,
) )
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
@ -554,7 +551,6 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None:
"test", "test",
intent.INTENT_CANCEL_TIMER, intent.INTENT_CANCEL_TIMER,
{"name": {"value": "does-not-exist"}}, {"name": {"value": "does-not-exist"}},
device_id=device_id,
) )
# Right start time # Right start time
@ -563,7 +559,6 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None:
"test", "test",
intent.INTENT_INCREASE_TIMER, intent.INTENT_INCREASE_TIMER,
{"start_minutes": {"value": 5}, "minutes": {"value": 1}}, {"start_minutes": {"value": 5}, "minutes": {"value": 1}},
device_id=device_id,
) )
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
@ -574,7 +569,6 @@ async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None:
"test", "test",
intent.INTENT_CANCEL_TIMER, intent.INTENT_CANCEL_TIMER,
{"start_minutes": {"value": 1}}, {"start_minutes": {"value": 1}},
device_id=device_id,
) )
@ -903,9 +897,7 @@ async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None
# Pause the timer # Pause the timer
expected_active = False expected_active = False
result = await intent.async_handle( result = await intent.async_handle(hass, "test", intent.INTENT_PAUSE_TIMER, {})
hass, "test", intent.INTENT_PAUSE_TIMER, {}, device_id=device_id
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1): async with asyncio.timeout(1):
@ -913,16 +905,12 @@ async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None
# Pausing again will fail because there are no running timers # Pausing again will fail because there are no running timers
with pytest.raises(TimerNotFoundError): with pytest.raises(TimerNotFoundError):
await intent.async_handle( await intent.async_handle(hass, "test", intent.INTENT_PAUSE_TIMER, {})
hass, "test", intent.INTENT_PAUSE_TIMER, {}, device_id=device_id
)
# Unpause the timer # Unpause the timer
updated_event.clear() updated_event.clear()
expected_active = True expected_active = True
result = await intent.async_handle( result = await intent.async_handle(hass, "test", intent.INTENT_UNPAUSE_TIMER, {})
hass, "test", intent.INTENT_UNPAUSE_TIMER, {}, device_id=device_id
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.response_type == intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1): async with asyncio.timeout(1):
@ -930,9 +918,7 @@ async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None
# Unpausing again will fail because there are no paused timers # Unpausing again will fail because there are no paused timers
with pytest.raises(TimerNotFoundError): with pytest.raises(TimerNotFoundError):
await intent.async_handle( await intent.async_handle(hass, "test", intent.INTENT_UNPAUSE_TIMER, {})
hass, "test", intent.INTENT_UNPAUSE_TIMER, {}, device_id=device_id
)
async def test_timer_not_found(hass: HomeAssistant) -> None: async def test_timer_not_found(hass: HomeAssistant) -> None:
@ -1101,13 +1087,14 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) ->
await started_event.wait() await started_event.wait()
# No constraints returns all timers # No constraints returns all timers
result = await intent.async_handle( for handle_device_id in (device_id, None):
hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_id result = await intent.async_handle(
) hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=handle_device_id
assert result.response_type == intent.IntentResponseType.ACTION_DONE )
timers = result.speech_slots.get("timers", []) assert result.response_type == intent.IntentResponseType.ACTION_DONE
assert len(timers) == 4 timers = result.speech_slots.get("timers", [])
assert {t.get(ATTR_NAME) for t in timers} == {"pizza", "cookies", "chicken"} assert len(timers) == 4
assert {t.get(ATTR_NAME) for t in timers} == {"pizza", "cookies", "chicken"}
# Get status of cookie timer # Get status of cookie timer
result = await intent.async_handle( result = await intent.async_handle(

View file

@ -578,7 +578,7 @@ async def test_assist_api_prompt(
"(what comes before the dot in its entity id). " "(what comes before the dot in its entity id). "
"When controlling an area, prefer passing just area name and domain." "When controlling an area, prefer passing just area name and domain."
) )
no_timer_prompt = "This device does not support timers." no_timer_prompt = "This device is not able to start timers."
area_prompt = ( area_prompt = (
"When a user asks to turn on all devices of a specific type, " "When a user asks to turn on all devices of a specific type, "