diff --git a/homeassistant/components/esphome/timer_list.py b/homeassistant/components/esphome/timer_list.py
index 82c538fc01ca..a9cb54dfb7dc 100644
--- a/homeassistant/components/esphome/timer_list.py
+++ b/homeassistant/components/esphome/timer_list.py
@@ -2,7 +2,7 @@
from functools import partial
-from aioesphomeapi import VoiceAssistantTimerEventType
+from aioesphomeapi import VoiceAssistantFeature, VoiceAssistantTimerEventType
from homeassistant.components.timer_list import (
InMemoryTimerListEntity,
@@ -46,11 +46,14 @@ async def async_setup_entry(
entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Set up the timer list for a voice-capable ESPHome device."""
+ """Set up the timer list for a timer-capable ESPHome device."""
entry_data = entry.runtime_data
device_info = entry_data.device_info
assert device_info is not None
- if not device_info.voice_assistant_feature_flags_compat(entry_data.api_version):
+ feature_flags = device_info.voice_assistant_feature_flags_compat(
+ entry_data.api_version
+ )
+ if not (feature_flags & VoiceAssistantFeature.TIMERS):
return
mac = device_info.mac_address
diff --git a/homeassistant/components/timer_list/local.py b/homeassistant/components/timer_list/local.py
index 3cbb309ef72b..739407cc4cd3 100644
--- a/homeassistant/components/timer_list/local.py
+++ b/homeassistant/components/timer_list/local.py
@@ -101,6 +101,9 @@ class InMemoryTimerListEntity(TimerListEntity):
async def async_cancel_timer(self, timer_id: str) -> None:
"""Cancel a timer, archiving it in the ``cancelled`` state."""
timer = self._get_timer(timer_id)
+ if timer.status in _FINISHED_STATUSES:
+ # Already archived (finished or cancelled); nothing to cancel.
+ return
self._unschedule(timer_id)
timer.status = TimerStatus.CANCELLED
timer.finishes_at = None
diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr
index 72b13befc4ce..905331d7ca82 100644
--- a/tests/components/cloud/snapshots/test_http_api.ambr
+++ b/tests/components/cloud/snapshots/test_http_api.ambr
@@ -21,7 +21,7 @@
## Active Integrations
- Built-in integrations: 23
+ Built-in integrations: 25
Custom integrations: 1
Built-in integrations
@@ -48,9 +48,11 @@
repairs | Repairs
stt | Speech-to-text (STT)
system_health | System Health
+ timer_list | Timer list
tts | Text-to-speech (TTS)
web_rtc | WebRTC
webhook | Webhook
+ websocket_api | Home Assistant WebSocket API
@@ -157,7 +159,7 @@
## Active Integrations
- Built-in integrations: 23
+ Built-in integrations: 25
Custom integrations: 0
Built-in integrations
@@ -184,9 +186,11 @@
repairs | Repairs
stt | Speech-to-text (STT)
system_health | System Health
+ timer_list | Timer list
tts | Text-to-speech (TTS)
web_rtc | WebRTC
webhook | Webhook
+ websocket_api | Home Assistant WebSocket API
diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py
index 7cb8d446324d..8a21f87f87e2 100644
--- a/tests/components/esphome/test_assist_satellite.py
+++ b/tests/components/esphome/test_assist_satellite.py
@@ -833,6 +833,24 @@ async def test_timer_events(
)
+async def test_no_timer_list_without_timers_flag(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ mock_client: APIClient,
+ mock_esphome_device: MockESPHomeDeviceType,
+) -> None:
+ """Test a voice device without the TIMERS flag gets no timer list entity."""
+ await mock_esphome_device(
+ mock_client=mock_client,
+ device_info={
+ "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.async_entity_ids("timer_list") == []
+
+
async def test_unknown_timer_event(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
diff --git a/tests/components/timer_list/test_init.py b/tests/components/timer_list/test_init.py
index dd688a73c5cf..154e774ad70f 100644
--- a/tests/components/timer_list/test_init.py
+++ b/tests/components/timer_list/test_init.py
@@ -172,6 +172,25 @@ async def test_cancel_timer_archives_timer(hass: HomeAssistant) -> None:
assert timers[0]["status"] == "cancelled"
+@pytest.mark.usefixtures("test_entity")
+async def test_cancel_already_archived_timer_is_noop(
+ hass: HomeAssistant, freezer: FrozenDateTimeFactory
+) -> None:
+ """Test cancelling an already-archived timer does not rewrite it."""
+ timer_id = await _start_timer(hass)
+ await _call(hass, "cancel_timer", timer_id=timer_id)
+ finished_at = (await _get_timers(hass))[0]["finished_at"]
+
+ # Cancelling again must not refresh the timestamp or re-fire a change
+ freezer.tick(timedelta(seconds=5))
+ await _call(hass, "cancel_timer", timer_id=timer_id)
+
+ timers = await _get_timers(hass)
+ assert len(timers) == 1
+ assert timers[0]["status"] == "cancelled"
+ assert timers[0]["finished_at"] == finished_at
+
+
@pytest.mark.usefixtures("test_entity")
async def test_archive_limit_evicts_oldest(
hass: HomeAssistant, freezer: FrozenDateTimeFactory