Compare commits

...

7 Commits

Author SHA1 Message Date
mib1185
53ce3bdf40 remove unneccesary parametrization 2026-02-23 22:42:00 +00:00
mib1185
da3c362c1f adjust trigger names 2026-02-23 22:34:13 +00:00
mib1185
e4289f8f24 make turn on trigger capable for back-to-back blocks 2026-02-23 22:28:52 +00:00
mib1185
b9acada665 update tests to latest patterns 2026-02-23 19:25:21 +00:00
mib1185
c0b1d02448 adjust trigger descriptions 2026-02-23 19:17:16 +00:00
Michael
37cf3a785b Merge branch 'dev' into schedule/add-domain-driven-triggers 2026-02-23 20:13:17 +01:00
mib1185
51ff1ca2d8 add domain driven triggers 2025-12-17 20:26:50 +00:00
8 changed files with 513 additions and 85 deletions

View File

@@ -150,6 +150,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"media_player",
"person",
"scene",
"schedule",
"siren",
"switch",
"text",

View File

@@ -6,5 +6,13 @@
"reload": {
"service": "mdi:reload"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:calendar-blank"
},
"turned_on": {
"trigger": "mdi:calendar-clock"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted schedules to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::schedule::title%]",
@@ -20,6 +24,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"get_schedule": {
"description": "Retrieves the configured time ranges of one or multiple schedules.",
@@ -30,5 +43,27 @@
"name": "[%key:common::action::reload%]"
}
},
"title": "Schedule"
"title": "Schedule",
"triggers": {
"turned_off": {
"description": "Triggers when a schedule block ends.",
"fields": {
"behavior": {
"description": "[%key:component::schedule::common::trigger_behavior_description%]",
"name": "[%key:component::schedule::common::trigger_behavior_name%]"
}
},
"name": "Schedule block ended"
},
"turned_on": {
"description": "Triggers when a schedule block starts.",
"fields": {
"behavior": {
"description": "[%key:component::schedule::common::trigger_behavior_description%]",
"name": "[%key:component::schedule::common::trigger_behavior_name%]"
}
},
"name": "Schedule block started"
}
}
}

View File

@@ -0,0 +1,43 @@
"""Provides triggers for schedulers."""
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import (
EntityTransitionTriggerBase,
Trigger,
make_entity_target_state_trigger,
)
from . import DOMAIN
from .const import ATTR_NEXT_EVENT
class ScheduleBackToBackTrigger(EntityTransitionTriggerBase):
"""Trigger for back-to-back schedule blocks."""
_domain = DOMAIN
_from_states = {STATE_OFF, STATE_ON}
_to_states = {STATE_ON}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state matches the expected ones."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT)
to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT)
return (
from_state.state in self._from_states and from_next_event != to_next_event
)
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": ScheduleBackToBackTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for schedulers."""
return TRIGGERS

View File

@@ -0,0 +1,18 @@
.trigger_common: &trigger_common
target:
entity:
domain: schedule
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -0,0 +1,108 @@
"""Test for the Schedule integration."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any
import pytest
from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR
from homeassistant.components.schedule.const import (
CONF_DATA,
CONF_FRIDAY,
CONF_FROM,
CONF_MONDAY,
CONF_SATURDAY,
CONF_SUNDAY,
CONF_THURSDAY,
CONF_TO,
CONF_TUESDAY,
CONF_WEDNESDAY,
DOMAIN,
)
from homeassistant.const import CONF_ICON, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@pytest.fixture
def schedule_setup(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> Callable[..., Coroutine[Any, Any, bool]]:
"""Schedule setup."""
async def _schedule_setup(
items: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
) -> bool:
if items is None:
hass_storage[DOMAIN] = {
"key": DOMAIN,
"version": STORAGE_VERSION,
"minor_version": STORAGE_VERSION_MINOR,
"data": {
"items": [
{
CONF_ID: "from_storage",
CONF_NAME: "from storage",
CONF_ICON: "mdi:party-popper",
CONF_FRIDAY: [
{
CONF_FROM: "17:00:00",
CONF_TO: "23:59:59",
CONF_DATA: {"party_level": "epic"},
},
],
CONF_SATURDAY: [
{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"},
],
CONF_SUNDAY: [
{
CONF_FROM: "00:00:00",
CONF_TO: "24:00:00",
CONF_DATA: {"entry": "VIPs only"},
},
],
}
]
},
}
else:
hass_storage[DOMAIN] = {
"key": DOMAIN,
"version": 1,
"minor_version": STORAGE_VERSION_MINOR,
"data": {"items": items},
}
if config is None:
config = {
DOMAIN: {
"from_yaml": {
CONF_NAME: "from yaml",
CONF_ICON: "mdi:party-pooper",
CONF_MONDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_TUESDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_WEDNESDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_THURSDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_FRIDAY: [
{
CONF_FROM: "00:00:00",
CONF_TO: "23:59:59",
CONF_DATA: {"party_level": "epic"},
}
],
CONF_SATURDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_SUNDAY: [
{
CONF_FROM: "00:00:00",
CONF_TO: "23:59:59",
CONF_DATA: {"entry": "VIPs only"},
}
],
}
}
}
return await async_setup_component(hass, DOMAIN, config)
return _schedule_setup

View File

@@ -10,7 +10,6 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR
from homeassistant.components.schedule.const import (
ATTR_NEXT_EVENT,
CONF_ALL_DAYS,
@@ -34,7 +33,6 @@ from homeassistant.const import (
ATTR_NAME,
CONF_ENTITY_ID,
CONF_ICON,
CONF_ID,
CONF_NAME,
EVENT_STATE_CHANGED,
SERVICE_RELOAD,
@@ -49,88 +47,6 @@ from tests.common import MockUser, async_capture_events, async_fire_time_changed
from tests.typing import WebSocketGenerator
@pytest.fixture
def schedule_setup(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> Callable[..., Coroutine[Any, Any, bool]]:
"""Schedule setup."""
async def _schedule_setup(
items: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
) -> bool:
if items is None:
hass_storage[DOMAIN] = {
"key": DOMAIN,
"version": STORAGE_VERSION,
"minor_version": STORAGE_VERSION_MINOR,
"data": {
"items": [
{
CONF_ID: "from_storage",
CONF_NAME: "from storage",
CONF_ICON: "mdi:party-popper",
CONF_FRIDAY: [
{
CONF_FROM: "17:00:00",
CONF_TO: "23:59:59",
CONF_DATA: {"party_level": "epic"},
},
],
CONF_SATURDAY: [
{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"},
],
CONF_SUNDAY: [
{
CONF_FROM: "00:00:00",
CONF_TO: "24:00:00",
CONF_DATA: {"entry": "VIPs only"},
},
],
}
]
},
}
else:
hass_storage[DOMAIN] = {
"key": DOMAIN,
"version": 1,
"minor_version": STORAGE_VERSION_MINOR,
"data": {"items": items},
}
if config is None:
config = {
DOMAIN: {
"from_yaml": {
CONF_NAME: "from yaml",
CONF_ICON: "mdi:party-pooper",
CONF_MONDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_TUESDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_WEDNESDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_THURSDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_FRIDAY: [
{
CONF_FROM: "00:00:00",
CONF_TO: "23:59:59",
CONF_DATA: {"party_level": "epic"},
}
],
CONF_SATURDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}],
CONF_SUNDAY: [
{
CONF_FROM: "00:00:00",
CONF_TO: "23:59:59",
CONF_DATA: {"entry": "VIPs only"},
}
],
}
}
}
return await async_setup_component(hass, DOMAIN, config)
return _schedule_setup
@pytest.mark.parametrize("invalid_config", [None, {"name with space": None}])
async def test_invalid_config(hass: HomeAssistant, invalid_config) -> None:
"""Test invalid configs."""

View File

@@ -0,0 +1,299 @@
"""Test schedule triggers."""
from collections.abc import Callable, Coroutine
from typing import Any
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.schedule.const import (
ATTR_NEXT_EVENT,
CONF_FROM,
CONF_SUNDAY,
CONF_TO,
DOMAIN,
)
from homeassistant.const import (
ATTR_LABEL_ID,
CONF_ENTITY_ID,
CONF_ICON,
CONF_NAME,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.common import async_fire_time_changed
from tests.components import (
TriggerStateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_schedules(hass: HomeAssistant) -> list[str]:
"""Create multiple schedule entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"schedule.turned_off",
"schedule.turned_on",
],
)
async def test_schedule_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the schedule triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="schedule.turned_off",
target_states=[(STATE_OFF, {ATTR_NEXT_EVENT: "2022-08-30T13:20:00-07:00"})],
other_states=[(STATE_ON, {ATTR_NEXT_EVENT: "2022-08-30T13:30:00-07:00"})],
),
*parametrize_trigger_states(
trigger="schedule.turned_on",
target_states=[(STATE_ON, {ATTR_NEXT_EVENT: "2022-08-30T13:20:00-07:00"})],
other_states=[(STATE_OFF, {ATTR_NEXT_EVENT: "2022-08-30T13:30:00-07:00"})],
),
],
)
async def test_schedule_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_schedules: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test that the schedule state trigger fires when any schedule state changes to a specific state."""
other_entity_ids = set(target_schedules) - {entity_id}
# Set all schedules, including the tested one, to the initial state
for eid in target_schedules:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other schedules also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="schedule.turned_off",
target_states=[(STATE_OFF, {ATTR_NEXT_EVENT: "2022-08-30T13:20:00-07:00"})],
other_states=[(STATE_ON, {ATTR_NEXT_EVENT: "2022-08-30T13:30:00-07:00"})],
),
*parametrize_trigger_states(
trigger="schedule.turned_on",
target_states=[(STATE_ON, {ATTR_NEXT_EVENT: "2022-08-30T13:20:00-07:00"})],
other_states=[(STATE_OFF, {ATTR_NEXT_EVENT: "2022-08-30T13:30:00-07:00"})],
),
],
)
async def test_schedule_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_schedules: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test that the schedule state trigger fires when the first schedule changes to a specific state."""
other_entity_ids = set(target_schedules) - {entity_id}
# Set all schedules, including the tested one, to the initial state
for eid in target_schedules:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other schedules should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="schedule.turned_off",
target_states=[(STATE_OFF, {ATTR_NEXT_EVENT: "2022-08-30T13:20:00-07:00"})],
other_states=[(STATE_ON, {ATTR_NEXT_EVENT: "2022-08-30T13:30:00-07:00"})],
),
*parametrize_trigger_states(
trigger="schedule.turned_on",
target_states=[(STATE_ON, {ATTR_NEXT_EVENT: "2022-08-30T13:20:00-07:00"})],
other_states=[(STATE_OFF, {ATTR_NEXT_EVENT: "2022-08-30T13:30:00-07:00"})],
),
],
)
async def test_schedule_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_schedules: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test that the schedule state trigger fires when the last schedule changes to a specific state."""
other_entity_ids = set(target_schedules) - {entity_id}
# Set all schedules, including the tested one, to the initial state
for eid in target_schedules:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
async def test_schedule_state_trigger_back_to_back(
hass: HomeAssistant,
service_calls: list[ServiceCall],
schedule_setup: Callable[..., Coroutine[Any, Any, bool]],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that the schedule state trigger fires when transitioning between two back-to-back schedule blocks."""
freezer.move_to("2022-08-30 13:20:00-07:00")
entity_id = "schedule.from_yaml"
assert await schedule_setup(
config={
DOMAIN: {
"from_yaml": {
CONF_NAME: "from yaml",
CONF_ICON: "mdi:party-popper",
CONF_SUNDAY: [
{CONF_FROM: "22:00:00", CONF_TO: "22:30:00"},
{CONF_FROM: "22:30:00", CONF_TO: "23:00:00"},
],
}
}
},
items=[],
)
await arm_trigger(
hass,
"schedule.turned_on",
{},
{"entity_id": [entity_id]},
)
# initial state
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:00:00-07:00"
# move time into first block
freezer.move_to(state.attributes[ATTR_NEXT_EVENT])
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:30:00-07:00"
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# move time into second block (back-to-back)
freezer.move_to(state.attributes[ATTR_NEXT_EVENT])
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T23:00:00-07:00"
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id