mirror of
https://github.com/home-assistant/core.git
synced 2025-07-12 15:57:06 +00:00
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:
parent
ba395fb9f3
commit
e168cb96e9
@ -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,
|
||||
}
|
||||
)
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user