diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index cca2e5a22ae..5ade839aacd 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -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, } ) diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index 7e458fed47e..71b2b7e256d 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -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)