Add area filter and rounded time to timers (#117527)

* Add area filter

* Add rounded time to status

* Fix test

* Extend test

* Increase test coverage
This commit is contained in:
Michael Hansen 2024-05-16 09:45:14 -05:00 committed by GitHub
parent ba395fb9f3
commit e168cb96e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 334 additions and 8 deletions

View File

@ -71,6 +71,9 @@ class TimerInfo:
area_id: str | None = None
"""Id of area that the device belongs to."""
area_name: str | None = None
"""Normalized name of the area that the device belongs to."""
floor_id: str | None = None
"""Id of floor that the device's area belongs to."""
@ -85,12 +88,9 @@ class TimerInfo:
return max(0, self.seconds - seconds_running)
@cached_property
def name_normalized(self) -> str | None:
def name_normalized(self) -> str:
"""Return normalized timer name."""
if self.name is None:
return None
return self.name.strip().casefold()
return _normalize_name(self.name or "")
def cancel(self) -> None:
"""Cancel the timer."""
@ -223,6 +223,7 @@ class TimerManager:
if device.area_id and (
area := area_registry.async_get_area(device.area_id)
):
timer.area_name = _normalize_name(area.name)
timer.floor_id = area.floor_id
self.timers[timer_id] = timer
@ -422,13 +423,26 @@ def _find_timer(
has_filter = True
name = slots["name"]["value"]
assert name is not None
name_norm = name.strip().casefold()
name_norm = _normalize_name(name)
matching_timers = [t for t in matching_timers if t.name_normalized == name_norm]
if len(matching_timers) == 1:
# Only 1 match
return matching_timers[0]
# Search by area name
area_name: str | None = None
if "area" in slots:
has_filter = True
area_name = slots["area"]["value"]
assert area_name is not None
area_name_norm = _normalize_name(area_name)
matching_timers = [t for t in matching_timers if t.area_name == area_name_norm]
if len(matching_timers) == 1:
# Only 1 match
return matching_timers[0]
# Use starting time to disambiguate
start_hours: int | None = None
if "start_hours" in slots:
@ -501,8 +515,9 @@ def _find_timer(
raise MultipleTimersMatchedError
_LOGGER.warning(
"Timer not found: name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s",
"Timer not found: name=%s, area=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s",
name,
area_name,
start_hours,
start_minutes,
start_seconds,
@ -524,13 +539,25 @@ def _find_timers(
if "name" in slots:
name = slots["name"]["value"]
assert name is not None
name_norm = name.strip().casefold()
name_norm = _normalize_name(name)
matching_timers = [t for t in matching_timers if t.name_normalized == name_norm]
if not matching_timers:
# No matches
return matching_timers
# Filter by area name
area_name: str | None = None
if "area" in slots:
area_name = slots["area"]["value"]
assert area_name is not None
area_name_norm = _normalize_name(area_name)
matching_timers = [t for t in matching_timers if t.area_name == area_name_norm]
if not matching_timers:
# No matches
return matching_timers
# Use starting time to filter, if present
start_hours: int | None = None
if "start_hours" in slots:
@ -590,6 +617,11 @@ def _find_timers(
return matching_timers
def _normalize_name(name: str) -> str:
"""Normalize name for comparison."""
return name.strip().casefold()
def _get_total_seconds(slots: dict[str, Any]) -> int:
"""Return the total number of seconds from hours/minutes/seconds slots."""
total_seconds = 0
@ -605,6 +637,55 @@ def _get_total_seconds(slots: dict[str, Any]) -> int:
return total_seconds
def _round_time(hours: int, minutes: int, seconds: int) -> tuple[int, int, int]:
"""Round time to a lower precision for feedback."""
if hours > 0:
# No seconds, round up above 45 minutes and down below 15
rounded_hours = hours
rounded_seconds = 0
if minutes > 45:
# 01:50:30 -> 02:00:00
rounded_hours += 1
rounded_minutes = 0
elif minutes < 15:
# 01:10:30 -> 01:00:00
rounded_minutes = 0
else:
# 01:25:30 -> 01:30:00
rounded_minutes = 30
elif minutes > 0:
# Round up above 45 seconds, down below 15
rounded_hours = 0
rounded_minutes = minutes
if seconds > 45:
# 00:01:50 -> 00:02:00
rounded_minutes += 1
rounded_seconds = 0
elif seconds < 15:
# 00:01:10 -> 00:01:00
rounded_seconds = 0
else:
# 00:01:25 -> 00:01:30
rounded_seconds = 30
else:
# Round up above 50 seconds, exact below 10, and down to nearest 10
# otherwise.
rounded_hours = 0
rounded_minutes = 0
if seconds > 50:
# 00:00:55 -> 00:01:00
rounded_minutes = 1
rounded_seconds = 0
elif seconds < 10:
# 00:00:09 -> 00:00:09
rounded_seconds = seconds
else:
# 00:01:25 -> 00:01:20
rounded_seconds = seconds - (seconds % 10)
return rounded_hours, rounded_minutes, rounded_seconds
class StartTimerIntentHandler(intent.IntentHandler):
"""Intent handler for starting a new timer."""
@ -655,6 +736,7 @@ class CancelTimerIntentHandler(intent.IntentHandler):
slot_schema = {
vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -677,6 +759,7 @@ class IncreaseTimerIntentHandler(intent.IntentHandler):
vol.Any("hours", "minutes", "seconds"): cv.positive_int,
vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -700,6 +783,7 @@ class DecreaseTimerIntentHandler(intent.IntentHandler):
vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int,
vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -722,6 +806,7 @@ class PauseTimerIntentHandler(intent.IntentHandler):
slot_schema = {
vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -743,6 +828,7 @@ class UnpauseTimerIntentHandler(intent.IntentHandler):
slot_schema = {
vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -764,6 +850,7 @@ class TimerStatusIntentHandler(intent.IntentHandler):
slot_schema = {
vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -778,6 +865,11 @@ class TimerStatusIntentHandler(intent.IntentHandler):
minutes, seconds = divmod(total_seconds, 60)
hours, minutes = divmod(minutes, 60)
# Get lower-precision time for feedback
rounded_hours, rounded_minutes, rounded_seconds = _round_time(
hours, minutes, seconds
)
statuses.append(
{
ATTR_ID: timer.id,
@ -791,6 +883,9 @@ class TimerStatusIntentHandler(intent.IntentHandler):
"hours_left": hours,
"minutes_left": minutes,
"seconds_left": seconds,
"rounded_hours_left": rounded_hours,
"rounded_minutes_left": rounded_minutes,
"rounded_seconds_left": rounded_seconds,
"total_seconds_left": total_seconds,
}
)

View File

@ -1,6 +1,7 @@
"""Tests for intent timers."""
import asyncio
from unittest.mock import patch
import pytest
@ -10,6 +11,7 @@ from homeassistant.components.intent.timers import (
TimerInfo,
TimerManager,
TimerNotFoundError,
_round_time,
async_register_timer_handler,
)
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
@ -238,6 +240,25 @@ async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
async with asyncio.timeout(1):
await started_event.wait()
# Adding 0 seconds has no effect
result = await intent.async_handle(
hass,
"test",
intent.INTENT_INCREASE_TIMER,
{
"start_hours": {"value": 1},
"start_minutes": {"value": 2},
"start_seconds": {"value": 3},
"hours": {"value": 0},
"minutes": {"value": 0},
"seconds": {"value": 0},
},
device_id=device_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
assert not updated_event.is_set()
# Add 30 seconds to the timer
result = await intent.async_handle(
hass,
@ -979,3 +1000,213 @@ async def test_timer_status_with_names(hass: HomeAssistant, init_components) ->
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 0
async def test_area_filter(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test targeting timers by area name."""
entry = MockConfigEntry()
entry.add_to_hass(hass)
area_kitchen = area_registry.async_create("kitchen")
device_kitchen = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=set(),
identifiers={("test", "kitchen-device")},
)
device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id)
area_living_room = area_registry.async_create("living room")
device_living_room = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=set(),
identifiers={("test", "living_room-device")},
)
device_registry.async_update_device(
device_living_room.id, area_id=area_living_room.id
)
started_event = asyncio.Event()
num_timers = 3
num_started = 0
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
nonlocal num_started
if event_type == TimerEventType.STARTED:
num_started += 1
if num_started == num_timers:
started_event.set()
async_register_timer_handler(hass, handle_timer)
# Start timers in different areas
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"name": {"value": "pizza"}, "minutes": {"value": 10}},
device_id=device_kitchen.id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"name": {"value": "tv"}, "minutes": {"value": 10}},
device_id=device_living_room.id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"name": {"value": "media"}, "minutes": {"value": 15}},
device_id=device_living_room.id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
# Wait for all timers to start
async with asyncio.timeout(1):
await started_event.wait()
# No constraints returns all timers
result = await intent.async_handle(hass, "test", intent.INTENT_TIMER_STATUS, {})
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == num_timers
assert {t.get(ATTR_NAME) for t in timers} == {"pizza", "tv", "media"}
# Filter by area (kitchen)
result = await intent.async_handle(
hass,
"test",
intent.INTENT_TIMER_STATUS,
{"area": {"value": "kitchen"}},
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 1
assert timers[0].get(ATTR_NAME) == "pizza"
# Filter by area (living room)
result = await intent.async_handle(
hass,
"test",
intent.INTENT_TIMER_STATUS,
{"area": {"value": "living room"}},
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 2
assert {t.get(ATTR_NAME) for t in timers} == {"tv", "media"}
# Filter by area + name
result = await intent.async_handle(
hass,
"test",
intent.INTENT_TIMER_STATUS,
{"area": {"value": "living room"}, "name": {"value": "tv"}},
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 1
assert timers[0].get(ATTR_NAME) == "tv"
# Filter by area + time
result = await intent.async_handle(
hass,
"test",
intent.INTENT_TIMER_STATUS,
{"area": {"value": "living room"}, "start_minutes": {"value": 15}},
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 1
assert timers[0].get(ATTR_NAME) == "media"
# Filter by area that doesn't exist
result = await intent.async_handle(
hass,
"test",
intent.INTENT_TIMER_STATUS,
{"area": {"value": "does-not-exist"}},
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 0
# Cancel by area + time
result = await intent.async_handle(
hass,
"test",
intent.INTENT_CANCEL_TIMER,
{"area": {"value": "living room"}, "start_minutes": {"value": 15}},
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
# Cancel by area
result = await intent.async_handle(
hass,
"test",
intent.INTENT_CANCEL_TIMER,
{"area": {"value": "living room"}},
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
# Get status with device missing
with patch(
"homeassistant.helpers.device_registry.DeviceRegistry.async_get",
return_value=None,
):
result = await intent.async_handle(
hass,
"test",
intent.INTENT_TIMER_STATUS,
device_id=device_kitchen.id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 1
# Get status with area missing
with patch(
"homeassistant.helpers.area_registry.AreaRegistry.async_get_area",
return_value=None,
):
result = await intent.async_handle(
hass,
"test",
intent.INTENT_TIMER_STATUS,
device_id=device_kitchen.id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
timers = result.speech_slots.get("timers", [])
assert len(timers) == 1
def test_round_time() -> None:
"""Test lower-precision time rounded."""
# hours
assert _round_time(1, 10, 30) == (1, 0, 0)
assert _round_time(1, 48, 30) == (2, 0, 0)
assert _round_time(2, 25, 30) == (2, 30, 0)
# minutes
assert _round_time(0, 1, 10) == (0, 1, 0)
assert _round_time(0, 1, 48) == (0, 2, 0)
assert _round_time(0, 2, 25) == (0, 2, 30)
# seconds
assert _round_time(0, 0, 6) == (0, 0, 6)
assert _round_time(0, 0, 15) == (0, 0, 10)
assert _round_time(0, 0, 58) == (0, 1, 0)
assert _round_time(0, 0, 25) == (0, 0, 20)
assert _round_time(0, 0, 35) == (0, 0, 30)