Support datetime sensor in time trigger (#42899)
This commit is contained in:
parent
03653f5d2c
commit
aec191b558
4 changed files with 165 additions and 20 deletions
|
@ -4,7 +4,14 @@ from functools import partial
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import CONF_AT, CONF_PLATFORM
|
from homeassistant.components import sensor
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_DEVICE_CLASS,
|
||||||
|
CONF_AT,
|
||||||
|
CONF_PLATFORM,
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
)
|
||||||
from homeassistant.core import HassJob, callback
|
from homeassistant.core import HassJob, callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.event import (
|
from homeassistant.helpers.event import (
|
||||||
|
@ -18,8 +25,8 @@ import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
_TIME_TRIGGER_SCHEMA = vol.Any(
|
_TIME_TRIGGER_SCHEMA = vol.Any(
|
||||||
cv.time,
|
cv.time,
|
||||||
vol.All(str, cv.entity_domain("input_datetime")),
|
vol.All(str, cv.entity_domain(("input_datetime", "sensor"))),
|
||||||
msg="Expected HH:MM, HH:MM:SS or Entity ID from domain 'input_datetime'",
|
msg="Expected HH:MM, HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'",
|
||||||
)
|
)
|
||||||
|
|
||||||
TRIGGER_SCHEMA = vol.Schema(
|
TRIGGER_SCHEMA = vol.Schema(
|
||||||
|
@ -60,14 +67,16 @@ async def async_attach_trigger(hass, config, action, automation_info):
|
||||||
def update_entity_trigger(entity_id, new_state=None):
|
def update_entity_trigger(entity_id, new_state=None):
|
||||||
"""Update the entity trigger for the entity_id."""
|
"""Update the entity trigger for the entity_id."""
|
||||||
# If a listener was already set up for entity, remove it.
|
# If a listener was already set up for entity, remove it.
|
||||||
remove = entities.get(entity_id)
|
remove = entities.pop(entity_id, None)
|
||||||
if remove:
|
if remove:
|
||||||
remove()
|
remove()
|
||||||
removes.remove(remove)
|
|
||||||
remove = None
|
remove = None
|
||||||
|
|
||||||
|
if not new_state:
|
||||||
|
return
|
||||||
|
|
||||||
# Check state of entity. If valid, set up a listener.
|
# Check state of entity. If valid, set up a listener.
|
||||||
if new_state:
|
if new_state.domain == "input_datetime":
|
||||||
has_date = new_state.attributes["has_date"]
|
has_date = new_state.attributes["has_date"]
|
||||||
if has_date:
|
if has_date:
|
||||||
year = new_state.attributes["year"]
|
year = new_state.attributes["year"]
|
||||||
|
@ -111,16 +120,32 @@ async def async_attach_trigger(hass, config, action, automation_info):
|
||||||
minute=minute,
|
minute=minute,
|
||||||
second=second,
|
second=second,
|
||||||
)
|
)
|
||||||
|
elif (
|
||||||
|
new_state.domain == "sensor"
|
||||||
|
and new_state.attributes.get(ATTR_DEVICE_CLASS)
|
||||||
|
== sensor.DEVICE_CLASS_TIMESTAMP
|
||||||
|
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||||
|
):
|
||||||
|
trigger_dt = dt_util.parse_datetime(new_state.state)
|
||||||
|
|
||||||
|
if trigger_dt is not None and trigger_dt > dt_util.utcnow():
|
||||||
|
remove = async_track_point_in_time(
|
||||||
|
hass,
|
||||||
|
partial(
|
||||||
|
time_automation_listener,
|
||||||
|
f"time set in {entity_id}",
|
||||||
|
entity_id=entity_id,
|
||||||
|
),
|
||||||
|
trigger_dt,
|
||||||
|
)
|
||||||
|
|
||||||
# Was a listener set up?
|
# Was a listener set up?
|
||||||
if remove:
|
if remove:
|
||||||
removes.append(remove)
|
|
||||||
|
|
||||||
entities[entity_id] = remove
|
entities[entity_id] = remove
|
||||||
|
|
||||||
for at_time in config[CONF_AT]:
|
for at_time in config[CONF_AT]:
|
||||||
if isinstance(at_time, str):
|
if isinstance(at_time, str):
|
||||||
# input_datetime entity
|
# entity
|
||||||
update_entity_trigger(at_time, new_state=hass.states.get(at_time))
|
update_entity_trigger(at_time, new_state=hass.states.get(at_time))
|
||||||
else:
|
else:
|
||||||
# datetime.time
|
# datetime.time
|
||||||
|
@ -144,6 +169,8 @@ async def async_attach_trigger(hass, config, action, automation_info):
|
||||||
@callback
|
@callback
|
||||||
def remove_track_time_changes():
|
def remove_track_time_changes():
|
||||||
"""Remove tracked time changes."""
|
"""Remove tracked time changes."""
|
||||||
|
for remove in entities.values():
|
||||||
|
remove()
|
||||||
for remove in removes:
|
for remove in removes:
|
||||||
remove()
|
remove()
|
||||||
|
|
||||||
|
|
|
@ -282,25 +282,36 @@ comp_entity_ids = vol.Any(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def entity_domain(domain: str) -> Callable[[Any], str]:
|
def entity_domain(domain: Union[str, List[str]]) -> Callable[[Any], str]:
|
||||||
"""Validate that entity belong to domain."""
|
"""Validate that entity belong to domain."""
|
||||||
|
ent_domain = entities_domain(domain)
|
||||||
|
|
||||||
def validate(value: Any) -> str:
|
def validate(value: Any) -> str:
|
||||||
"""Test if entity domain is domain."""
|
"""Test if entity domain is domain."""
|
||||||
ent_domain = entities_domain(domain)
|
|
||||||
return ent_domain(value)[0]
|
return ent_domain(value)[0]
|
||||||
|
|
||||||
return validate
|
return validate
|
||||||
|
|
||||||
|
|
||||||
def entities_domain(domain: str) -> Callable[[Union[str, List]], List[str]]:
|
def entities_domain(
|
||||||
|
domain: Union[str, List[str]]
|
||||||
|
) -> Callable[[Union[str, List]], List[str]]:
|
||||||
"""Validate that entities belong to domain."""
|
"""Validate that entities belong to domain."""
|
||||||
|
if isinstance(domain, str):
|
||||||
|
|
||||||
|
def check_invalid(val: str) -> bool:
|
||||||
|
return val != domain
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def check_invalid(val: str) -> bool:
|
||||||
|
return val not in domain
|
||||||
|
|
||||||
def validate(values: Union[str, List]) -> List[str]:
|
def validate(values: Union[str, List]) -> List[str]:
|
||||||
"""Test if entity domain is domain."""
|
"""Test if entity domain is domain."""
|
||||||
values = entity_ids(values)
|
values = entity_ids(values)
|
||||||
for ent_id in values:
|
for ent_id in values:
|
||||||
if split_entity_id(ent_id)[0] != domain:
|
if check_invalid(split_entity_id(ent_id)[0]):
|
||||||
raise vol.Invalid(
|
raise vol.Invalid(
|
||||||
f"Entity ID '{ent_id}' does not belong to domain '{domain}'"
|
f"Entity ID '{ent_id}' does not belong to domain '{domain}'"
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,8 +3,8 @@ from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import homeassistant.components.automation as automation
|
from homeassistant.components import automation, sensor
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF
|
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, SERVICE_TURN_OFF
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
|
@ -391,3 +391,104 @@ async def test_untrack_time_change(hass):
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(mock_track_time_change.mock_calls) == 3
|
assert len(mock_track_time_change.mock_calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_if_fires_using_at_sensor(hass, calls):
|
||||||
|
"""Test for firing at sensor time."""
|
||||||
|
now = dt_util.now()
|
||||||
|
|
||||||
|
trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2)
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.next_alarm",
|
||||||
|
trigger_dt.isoformat(),
|
||||||
|
{ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TIMESTAMP},
|
||||||
|
)
|
||||||
|
|
||||||
|
time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1)
|
||||||
|
|
||||||
|
some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}"
|
||||||
|
with patch(
|
||||||
|
"homeassistant.util.dt.utcnow",
|
||||||
|
return_value=dt_util.as_utc(time_that_will_not_match_right_away),
|
||||||
|
):
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
automation.DOMAIN,
|
||||||
|
{
|
||||||
|
automation.DOMAIN: {
|
||||||
|
"trigger": {"platform": "time", "at": "sensor.next_alarm"},
|
||||||
|
"action": {
|
||||||
|
"service": "test.automation",
|
||||||
|
"data_template": {"some": some_data},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert (
|
||||||
|
calls[0].data["some"]
|
||||||
|
== f"time-{trigger_dt.day}-{trigger_dt.hour}-sensor.next_alarm"
|
||||||
|
)
|
||||||
|
|
||||||
|
trigger_dt += timedelta(days=1, hours=1)
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.next_alarm",
|
||||||
|
trigger_dt.isoformat(),
|
||||||
|
{ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TIMESTAMP},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert (
|
||||||
|
calls[1].data["some"]
|
||||||
|
== f"time-{trigger_dt.day}-{trigger_dt.hour}-sensor.next_alarm"
|
||||||
|
)
|
||||||
|
|
||||||
|
for broken in ("unknown", "unavailable", "invalid-ts"):
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.next_alarm",
|
||||||
|
trigger_dt.isoformat(),
|
||||||
|
{ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TIMESTAMP},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.next_alarm",
|
||||||
|
broken,
|
||||||
|
{ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TIMESTAMP},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# We should not have listened to anything
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
# Now without device class
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.next_alarm",
|
||||||
|
trigger_dt.isoformat(),
|
||||||
|
{ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TIMESTAMP},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.next_alarm",
|
||||||
|
trigger_dt.isoformat(),
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# We should not have listened to anything
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
|
@ -179,15 +179,21 @@ def test_entity_domain():
|
||||||
"""Test entity domain validation."""
|
"""Test entity domain validation."""
|
||||||
schema = vol.Schema(cv.entity_domain("sensor"))
|
schema = vol.Schema(cv.entity_domain("sensor"))
|
||||||
|
|
||||||
options = ("invalid_entity", "cover.demo")
|
for value in ("invalid_entity", "cover.demo"):
|
||||||
|
|
||||||
for value in options:
|
|
||||||
with pytest.raises(vol.MultipleInvalid):
|
with pytest.raises(vol.MultipleInvalid):
|
||||||
print(value)
|
|
||||||
schema(value)
|
schema(value)
|
||||||
|
|
||||||
assert schema("sensor.LIGHT") == "sensor.light"
|
assert schema("sensor.LIGHT") == "sensor.light"
|
||||||
|
|
||||||
|
schema = vol.Schema(cv.entity_domain(("sensor", "binary_sensor")))
|
||||||
|
|
||||||
|
for value in ("invalid_entity", "cover.demo"):
|
||||||
|
with pytest.raises(vol.MultipleInvalid):
|
||||||
|
schema(value)
|
||||||
|
|
||||||
|
assert schema("sensor.LIGHT") == "sensor.light"
|
||||||
|
assert schema("binary_sensor.LIGHT") == "binary_sensor.light"
|
||||||
|
|
||||||
|
|
||||||
def test_entities_domain():
|
def test_entities_domain():
|
||||||
"""Test entities domain validation."""
|
"""Test entities domain validation."""
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue