Home Assistant Unofficial Reference 2024.12.1
time.py
Go to the documentation of this file.
1 """Offer time listening automation rules."""
2 
3 from collections.abc import Callable
4 from datetime import datetime, timedelta
5 from functools import partial
6 from typing import Any, NamedTuple
7 
8 import voluptuous as vol
9 
10 from homeassistant.components import sensor
11 from homeassistant.const import (
12  ATTR_DEVICE_CLASS,
13  CONF_AT,
14  CONF_ENTITY_ID,
15  CONF_OFFSET,
16  CONF_PLATFORM,
17  STATE_UNAVAILABLE,
18  STATE_UNKNOWN,
19 )
20 from homeassistant.core import (
21  CALLBACK_TYPE,
22  Event,
23  EventStateChangedData,
24  HassJob,
25  HomeAssistant,
26  State,
27  callback,
28 )
29 from homeassistant.exceptions import HomeAssistantError
30 from homeassistant.helpers import config_validation as cv, template
31 from homeassistant.helpers.event import (
32  async_track_point_in_time,
33  async_track_state_change_event,
34  async_track_time_change,
35 )
36 from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
37 from homeassistant.helpers.typing import ConfigType
38 import homeassistant.util.dt as dt_util
39 
40 _TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"]))
41 _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY)
42 
43 _TIME_TRIGGER_ENTITY_WITH_OFFSET = vol.Schema(
44  {
45  vol.Required(CONF_ENTITY_ID): cv.entity_domain(["sensor"]),
46  vol.Optional(CONF_OFFSET): cv.time_period,
47  }
48 )
49 
50 
51 def valid_at_template(value: Any) -> template.Template:
52  """Validate either a jinja2 template, valid time, or valid trigger entity."""
53  tpl = cv.template(value)
54 
55  if tpl.is_static:
56  _TIME_AT_SCHEMA(value)
57 
58  return tpl
59 
60 
61 _TIME_TRIGGER_SCHEMA = vol.Any(
62  cv.time,
63  _TIME_TRIGGER_ENTITY,
64  _TIME_TRIGGER_ENTITY_WITH_OFFSET,
65  valid_at_template,
66  msg=(
67  "Expected HH:MM, HH:MM:SS, an Entity ID with domain 'input_datetime' or "
68  "'sensor', a combination of a timestamp sensor entity and an offset, or Limited Template"
69  ),
70 )
71 
72 
73 TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
74  {
75  vol.Required(CONF_PLATFORM): "time",
76  vol.Required(CONF_AT): vol.All(cv.ensure_list, [_TIME_TRIGGER_SCHEMA]),
77  }
78 )
79 
80 
81 class TrackEntity(NamedTuple):
82  """Represents a tracking entity for a time trigger."""
83 
84  entity_id: str
85  callback: Callable
86 
87 
89  hass: HomeAssistant,
90  config: ConfigType,
91  action: TriggerActionType,
92  trigger_info: TriggerInfo,
93 ) -> CALLBACK_TYPE:
94  """Listen for state changes based on configuration."""
95  trigger_data = trigger_info["trigger_data"]
96  variables = trigger_info["variables"] or {}
97  entities: dict[tuple[str, timedelta], CALLBACK_TYPE] = {}
98  removes: list[CALLBACK_TYPE] = []
99  job = HassJob(action, f"time trigger {trigger_info}")
100 
101  @callback
102  def time_automation_listener(
103  description: str, now: datetime, *, entity_id: str | None = None
104  ) -> None:
105  """Listen for time changes and calls action."""
106  hass.async_run_hass_job(
107  job,
108  {
109  "trigger": {
110  **trigger_data,
111  "platform": "time",
112  "now": now,
113  "description": description,
114  "entity_id": entity_id,
115  }
116  },
117  )
118 
119  @callback
120  def update_entity_trigger_event(
121  event: Event[EventStateChangedData], offset: timedelta = timedelta(0)
122  ) -> None:
123  """update_entity_trigger from the event."""
124  return update_entity_trigger(
125  event.data["entity_id"], event.data["new_state"], offset
126  )
127 
128  @callback
129  def update_entity_trigger(
130  entity_id: str, new_state: State | None = None, offset: timedelta = timedelta(0)
131  ) -> None:
132  """Update the entity trigger for the entity_id."""
133  # If a listener was already set up for entity, remove it.
134  if remove := entities.pop((entity_id, offset), None):
135  remove()
136  remove = None
137 
138  if not new_state:
139  return
140 
141  trigger_dt: datetime | None
142 
143  # Check state of entity. If valid, set up a listener.
144  if new_state.domain == "input_datetime":
145  if has_date := new_state.attributes["has_date"]:
146  year = new_state.attributes["year"]
147  month = new_state.attributes["month"]
148  day = new_state.attributes["day"]
149  if has_time := new_state.attributes["has_time"]:
150  hour = new_state.attributes["hour"]
151  minute = new_state.attributes["minute"]
152  second = new_state.attributes["second"]
153  else:
154  # If no time then use midnight.
155  hour = minute = second = 0
156 
157  if has_date:
158  # If input_datetime has date, then track point in time.
159  trigger_dt = datetime(
160  year,
161  month,
162  day,
163  hour,
164  minute,
165  second,
166  tzinfo=dt_util.get_default_time_zone(),
167  )
168  # Only set up listener if time is now or in the future.
169  if trigger_dt >= dt_util.now():
170  remove = async_track_point_in_time(
171  hass,
172  partial(
173  time_automation_listener,
174  f"time set in {entity_id}",
175  entity_id=entity_id,
176  ),
177  trigger_dt,
178  )
179  elif has_time:
180  # Else if it has time, then track time change.
181  remove = async_track_time_change(
182  hass,
183  partial(
184  time_automation_listener,
185  f"time set in {entity_id}",
186  entity_id=entity_id,
187  ),
188  hour=hour,
189  minute=minute,
190  second=second,
191  )
192  elif (
193  new_state.domain == "sensor"
194  and new_state.attributes.get(ATTR_DEVICE_CLASS)
195  == sensor.SensorDeviceClass.TIMESTAMP
196  and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
197  ):
198  trigger_dt = dt_util.parse_datetime(new_state.state)
199 
200  if trigger_dt is not None:
201  trigger_dt += offset
202 
203  if trigger_dt is not None and trigger_dt > dt_util.utcnow():
204  remove = async_track_point_in_time(
205  hass,
206  partial(
207  time_automation_listener,
208  f"time set in {entity_id}",
209  entity_id=entity_id,
210  ),
211  trigger_dt,
212  )
213 
214  # Was a listener set up?
215  if remove:
216  entities[(entity_id, offset)] = remove
217 
218  to_track: list[TrackEntity] = []
219 
220  for at_time in config[CONF_AT]:
221  if isinstance(at_time, template.Template):
222  render = template.render_complex(at_time, variables, limited=True)
223  try:
224  at_time = _TIME_AT_SCHEMA(render)
225  except vol.Invalid as exc:
226  raise HomeAssistantError(
227  f"Limited Template for 'at' rendered a unexpected value '{render}', expected HH:MM, "
228  f"HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'"
229  ) from exc
230 
231  if isinstance(at_time, str):
232  # entity
233  update_entity_trigger(at_time, new_state=hass.states.get(at_time))
234  to_track.append(TrackEntity(at_time, update_entity_trigger_event))
235  elif isinstance(at_time, dict) and CONF_OFFSET in at_time:
236  # entity with offset
237  entity_id: str = at_time.get(CONF_ENTITY_ID, "")
238  offset: timedelta = at_time.get(CONF_OFFSET, timedelta(0))
239  update_entity_trigger(
240  entity_id, new_state=hass.states.get(entity_id), offset=offset
241  )
242  to_track.append(
243  TrackEntity(
244  entity_id, partial(update_entity_trigger_event, offset=offset)
245  )
246  )
247  else:
248  # datetime.time
249  removes.append(
251  hass,
252  partial(time_automation_listener, "time"),
253  hour=at_time.hour,
254  minute=at_time.minute,
255  second=at_time.second,
256  )
257  )
258 
259  # Besides time, we also track state changes of requested entities.
260  removes.extend(
261  (async_track_state_change_event(hass, entry.entity_id, entry.callback))
262  for entry in to_track
263  )
264 
265  @callback
266  def remove_track_time_changes() -> None:
267  """Remove tracked time changes."""
268  for remove in entities.values():
269  remove()
270  for remove in removes:
271  remove()
272 
273  return remove_track_time_changes
bool remove(self, _T matcher)
Definition: match.py:214
CALLBACK_TYPE async_attach_trigger(HomeAssistant hass, ConfigType config, TriggerActionType action, TriggerInfo trigger_info)
Definition: time.py:93
template.Template valid_at_template(Any value)
Definition: time.py:51
CALLBACK_TYPE async_track_time_change(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, Any|None hour=None, Any|None minute=None, Any|None second=None)
Definition: event.py:1904
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314
CALLBACK_TYPE async_track_point_in_time(HomeAssistant hass, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action, datetime point_in_time)
Definition: event.py:1462