mirror of
https://github.com/home-assistant/core.git
synced 2026-08-31 18:24:50 -05:00
Add trigger timer.time_remaining (#169763)
This commit is contained in:
@@ -45,6 +45,9 @@
|
||||
},
|
||||
"started": {
|
||||
"trigger": "mdi:timer-play"
|
||||
},
|
||||
"time_remaining": {
|
||||
"trigger": "mdi:timer-alert-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,15 @@
|
||||
}
|
||||
},
|
||||
"name": "Timer started"
|
||||
},
|
||||
"time_remaining": {
|
||||
"description": "Triggers when one or more timers reach a specific remaining time.",
|
||||
"fields": {
|
||||
"remaining": {
|
||||
"name": "Time remaining"
|
||||
}
|
||||
},
|
||||
"name": "Timer time remaining"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,161 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS
|
||||
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec, filter_by_domain_specs
|
||||
from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE
|
||||
|
||||
CONF_REMAINING = "remaining"
|
||||
|
||||
TIME_REMAINING_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_REMAINING): cv.positive_time_period_dict,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TimeRemainingTrigger(Trigger):
|
||||
"""Trigger when a timer has a specific amount of time remaining."""
|
||||
|
||||
_domain_specs: dict[str, DomainSpec] = {DOMAIN: DomainSpec()}
|
||||
_schema = TIME_REMAINING_TRIGGER_SCHEMA
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, cls._schema(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the time remaining trigger."""
|
||||
super().__init__(hass, config)
|
||||
assert config.target is not None
|
||||
self._target = config.target
|
||||
options = config.options or {}
|
||||
self._remaining: timedelta = options[CONF_REMAINING]
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities to timer domain."""
|
||||
return filter_by_domain_specs(self._hass, self._domain_specs, entities)
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
scheduled: dict[str, CALLBACK_TYPE] = {}
|
||||
|
||||
@callback
|
||||
def schedule_for_state(
|
||||
entity_id: str,
|
||||
to_state: State | None,
|
||||
context: Context | None,
|
||||
) -> None:
|
||||
"""Schedule a fire for an active timer state, if applicable."""
|
||||
if to_state is None:
|
||||
return
|
||||
if to_state.state != STATUS_ACTIVE:
|
||||
return
|
||||
|
||||
finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT)
|
||||
if finishes_at_str is None:
|
||||
return
|
||||
|
||||
finishes_at = dt_util.parse_datetime(finishes_at_str)
|
||||
if finishes_at is None:
|
||||
return
|
||||
|
||||
fire_at = finishes_at - self._remaining
|
||||
if fire_at <= dt_util.utcnow():
|
||||
return
|
||||
|
||||
@callback
|
||||
def fire_trigger(now: datetime) -> None:
|
||||
"""Fire the trigger."""
|
||||
scheduled.pop(entity_id, None)
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"to_state": to_state,
|
||||
"remaining": self._remaining,
|
||||
},
|
||||
f"time remaining of {entity_id}",
|
||||
context,
|
||||
)
|
||||
|
||||
scheduled[entity_id] = async_track_point_in_utc_time(
|
||||
self._hass, fire_trigger, fire_at
|
||||
)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and schedule trigger."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id: str = event.data["entity_id"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
# Cancel any previously scheduled callback for this entity
|
||||
if entity_id in scheduled:
|
||||
scheduled.pop(entity_id)()
|
||||
|
||||
schedule_for_state(entity_id, to_state, event.context)
|
||||
|
||||
@callback
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
"""Handle changes to the tracked entity set."""
|
||||
for entity_id in removed:
|
||||
if entity_id in scheduled:
|
||||
scheduled.pop(entity_id)()
|
||||
for entity_id in added:
|
||||
state = self._hass.states.get(entity_id)
|
||||
schedule_for_state(entity_id, state, state.context if state else None)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
self._hass,
|
||||
self._target,
|
||||
state_change_listener,
|
||||
self.entity_filter,
|
||||
on_entities_update,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_remove() -> None:
|
||||
"""Remove state listeners."""
|
||||
unsub()
|
||||
for cancel in scheduled.values():
|
||||
cancel()
|
||||
scheduled.clear()
|
||||
|
||||
return async_remove
|
||||
|
||||
from . import ATTR_LAST_TRANSITION, DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"cancelled": make_entity_target_state_trigger(
|
||||
@@ -24,6 +174,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started"
|
||||
),
|
||||
"time_remaining": TimeRemainingTrigger,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,3 +20,13 @@ finished: *trigger_common
|
||||
paused: *trigger_common
|
||||
restarted: *trigger_common
|
||||
started: *trigger_common
|
||||
|
||||
time_remaining:
|
||||
target:
|
||||
entity:
|
||||
domain: timer
|
||||
fields:
|
||||
remaining:
|
||||
required: true
|
||||
selector:
|
||||
duration:
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
"""Test timer triggers."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.timer import (
|
||||
ATTR_FINISHES_AT,
|
||||
ATTR_LAST_TRANSITION,
|
||||
DOMAIN,
|
||||
STATUS_ACTIVE,
|
||||
STATUS_IDLE,
|
||||
STATUS_PAUSED,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er, label_registry as lr
|
||||
from homeassistant.helpers.trigger import (
|
||||
async_initialize_triggers,
|
||||
async_validate_trigger_config,
|
||||
)
|
||||
from homeassistant.helpers.typing import TemplateVarsType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.components.common import (
|
||||
TriggerStateDescription,
|
||||
assert_trigger_behavior_any,
|
||||
@@ -40,6 +60,7 @@ async def target_timers(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"timer.paused",
|
||||
"timer.restarted",
|
||||
"timer.started",
|
||||
"timer.time_remaining",
|
||||
],
|
||||
)
|
||||
async def test_timer_triggers_gated_by_labs_flag(
|
||||
@@ -58,6 +79,7 @@ async def test_timer_triggers_gated_by_labs_flag(
|
||||
("timer.paused", {}, True, True),
|
||||
("timer.restarted", {}, True, True),
|
||||
("timer.started", {}, True, True),
|
||||
("timer.time_remaining", {"remaining": {"hours": 1}}, False, False),
|
||||
],
|
||||
)
|
||||
async def test_timer_trigger_options_validation(
|
||||
@@ -249,3 +271,490 @@ async def test_timer_trigger_behavior_last(
|
||||
trigger_options=trigger_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
|
||||
# --- time_remaining trigger tests ---
|
||||
|
||||
|
||||
async def _arm_time_remaining_trigger(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
remaining: dict[str, int],
|
||||
calls: list[dict[str, Any]],
|
||||
*,
|
||||
target: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Arm the time_remaining trigger."""
|
||||
trigger_config = await async_validate_trigger_config(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
CONF_PLATFORM: "timer.time_remaining",
|
||||
CONF_TARGET: target or {CONF_ENTITY_ID: entity_id},
|
||||
CONF_OPTIONS: {"remaining": remaining},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
@callback
|
||||
def action(run_variables: TemplateVarsType, context: Context | None = None) -> None:
|
||||
calls.append(run_variables["trigger"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def log_cb(level: int, msg: str, **kwargs: Any) -> None:
|
||||
logger._log(level, "%s", msg, **kwargs)
|
||||
|
||||
await async_initialize_triggers(
|
||||
hass,
|
||||
trigger_config,
|
||||
action,
|
||||
domain="test",
|
||||
name="test_trigger",
|
||||
log_cb=log_cb,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_validation(hass: HomeAssistant) -> None:
|
||||
"""Test time_remaining trigger config validation."""
|
||||
# Valid config
|
||||
await async_validate_trigger_config(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
CONF_PLATFORM: "timer.time_remaining",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "timer.test"},
|
||||
CONF_OPTIONS: {"remaining": {"seconds": 30}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# Missing remaining option
|
||||
with pytest.raises(vol.Invalid):
|
||||
await async_validate_trigger_config(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
CONF_PLATFORM: "timer.time_remaining",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "timer.test"},
|
||||
CONF_OPTIONS: {},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_fires(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test time_remaining trigger fires at the right time."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# Start timer with 60 second duration
|
||||
finishes_at = now + timedelta(seconds=60)
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Advance to 25 seconds - 35 seconds remaining, should not fire
|
||||
freezer.move_to(now + timedelta(seconds=25))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Advance to 30 seconds - 30 seconds remaining, should fire
|
||||
freezer.move_to(now + timedelta(seconds=30))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == "timer.test"
|
||||
assert calls[0]["remaining"] == timedelta(seconds=30)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_paused_before_threshold(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test time_remaining trigger does not fire when timer is paused before threshold."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# Start timer with 60 second duration
|
||||
finishes_at = now + timedelta(seconds=60)
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Pause timer at 10 seconds (before the 30-second threshold)
|
||||
freezer.move_to(now + timedelta(seconds=10))
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_PAUSED,
|
||||
{ATTR_LAST_TRANSITION: "paused"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original fire time - should not fire since paused
|
||||
freezer.move_to(now + timedelta(seconds=35))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_cancelled_before_threshold(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test time_remaining trigger does not fire when timer is cancelled before threshold."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# Start timer with 60 second duration
|
||||
finishes_at = now + timedelta(seconds=60)
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Cancel timer at 10 seconds
|
||||
freezer.move_to(now + timedelta(seconds=10))
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_IDLE,
|
||||
{ATTR_LAST_TRANSITION: "cancelled"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original fire time - should not fire since cancelled
|
||||
freezer.move_to(now + timedelta(seconds=35))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_restarted(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test time_remaining trigger reschedules when timer is restarted."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# Start timer with 60 second duration
|
||||
finishes_at = now + timedelta(seconds=60)
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Restart timer at 10 seconds with a new 60-second duration
|
||||
freezer.move_to(now + timedelta(seconds=10))
|
||||
new_finishes_at = now + timedelta(seconds=70) # 10s elapsed + 60s new
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{
|
||||
ATTR_LAST_TRANSITION: "restarted",
|
||||
ATTR_FINISHES_AT: new_finishes_at.isoformat(),
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Original fire time (30s) should not fire since rescheduled
|
||||
freezer.move_to(now + timedelta(seconds=30))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# New fire time: new_finishes_at - 30s = now + 40s
|
||||
freezer.move_to(now + timedelta(seconds=40))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_short_timer(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test time_remaining trigger does not fire when timer duration is shorter than remaining threshold."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# Start timer with only 20 second duration (less than 30s threshold)
|
||||
finishes_at = now + timedelta(seconds=20)
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# fire_at = now + 20 - 30 = now - 10 (in the past), should not schedule
|
||||
# Advance past the timer's end time
|
||||
freezer.move_to(now + timedelta(seconds=25))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_already_active_at_attach(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test trigger schedules for timers already active when the trigger attaches."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
# Timer is already active before the trigger is armed
|
||||
finishes_at = now + timedelta(seconds=60)
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# No fire yet
|
||||
assert len(calls) == 0
|
||||
|
||||
# Before fire_at (finishes_at - 30s = now + 30s) — should not fire
|
||||
freezer.move_to(now + timedelta(seconds=25))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# At fire_at — should fire even though no state change occurred
|
||||
freezer.move_to(now + timedelta(seconds=30))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == "timer.test"
|
||||
assert calls[0]["remaining"] == timedelta(seconds=30)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_already_active_past_threshold_at_attach(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test trigger does not schedule for timers already past the fire point at attach."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
# Timer is active but only 20 seconds remain — past the 30s threshold already
|
||||
finishes_at = now + timedelta(seconds=20)
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# Advance past the timer's finishing time — should never fire
|
||||
freezer.move_to(now + timedelta(seconds=25))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_idle_at_attach(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test trigger does not schedule for non-active timers at attach time."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# Even far in the future, no fire because timer never started
|
||||
freezer.move_to(now + timedelta(seconds=120))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_active_on_first_state_event(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test trigger schedules when first observed state event has no from_state.
|
||||
|
||||
This simulates a timer entity that is created/restored after the trigger
|
||||
is attached and appears directly in active state (e.g., RestoreEntity on
|
||||
restart), where the initial state-change event has from_state=None.
|
||||
"""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
|
||||
|
||||
# First state event for the entity has no old_state
|
||||
finishes_at = now + timedelta(seconds=60)
|
||||
hass.states.async_set(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Advance to fire time — should still fire even though from_state was None
|
||||
freezer.move_to(now + timedelta(seconds=30))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == "timer.test"
|
||||
assert calls[0]["remaining"] == timedelta(seconds=30)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_entity_removed_from_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test trigger cancels scheduled fire when entity is removed from the target."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Test Time Remaining")
|
||||
|
||||
entry = entity_registry.async_get_or_create(
|
||||
domain=DOMAIN, platform="test", unique_id="time_remaining_remove"
|
||||
)
|
||||
entity_registry.async_update_entity(entry.entity_id, labels={label.label_id})
|
||||
|
||||
hass.states.async_set(entry.entity_id, STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(
|
||||
hass,
|
||||
entry.entity_id,
|
||||
{"seconds": 30},
|
||||
calls,
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
# Start the timer — this schedules a fire via the state-change path
|
||||
finishes_at = now + timedelta(seconds=60)
|
||||
hass.states.async_set(
|
||||
entry.entity_id,
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Remove the entity from the target by stripping its label
|
||||
freezer.move_to(now + timedelta(seconds=10))
|
||||
entity_registry.async_update_entity(entry.entity_id, labels=set())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original fire time — should not fire since cancelled
|
||||
freezer.move_to(now + timedelta(seconds=35))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_time_remaining_trigger_entity_added_to_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test trigger schedules a fire for an active timer added to the target later."""
|
||||
now = dt_util.utcnow()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Test Time Remaining Add")
|
||||
|
||||
entry = entity_registry.async_get_or_create(
|
||||
domain=DOMAIN, platform="test", unique_id="time_remaining_add"
|
||||
)
|
||||
|
||||
# Timer is active, but not in the target yet
|
||||
finishes_at = now + timedelta(seconds=60)
|
||||
hass.states.async_set(
|
||||
entry.entity_id,
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _arm_time_remaining_trigger(
|
||||
hass,
|
||||
entry.entity_id,
|
||||
{"seconds": 30},
|
||||
calls,
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
# Now label the entity so it joins the target
|
||||
entity_registry.async_update_entity(entry.entity_id, labels={label.label_id})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance to the fire time — should fire even though no state change occurred
|
||||
freezer.move_to(now + timedelta(seconds=30))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry.entity_id
|
||||
assert calls[0]["remaining"] == timedelta(seconds=30)
|
||||
|
||||
Reference in New Issue
Block a user