Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for schedules in Home Assistant."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from datetime import datetime, time, timedelta
7 import itertools
8 from typing import Any, Literal
9 
10 import voluptuous as vol
11 
12 from homeassistant.const import (
13  ATTR_EDITABLE,
14  CONF_ICON,
15  CONF_ID,
16  CONF_NAME,
17  SERVICE_RELOAD,
18  STATE_OFF,
19  STATE_ON,
20 )
21 from homeassistant.core import HomeAssistant, ServiceCall, callback
23  CollectionEntity,
24  DictStorageCollection,
25  DictStorageCollectionWebsocket,
26  IDManager,
27  SerializedStorageCollection,
28  YamlCollection,
29  sync_entity_lifecycle,
30 )
32 from homeassistant.helpers.entity_component import EntityComponent
33 from homeassistant.helpers.event import async_track_point_in_utc_time
34 from homeassistant.helpers.service import async_register_admin_service
35 from homeassistant.helpers.storage import Store
36 from homeassistant.helpers.typing import ConfigType, VolDictType
37 from homeassistant.util import dt as dt_util
38 
39 from .const import (
40  ATTR_NEXT_EVENT,
41  CONF_ALL_DAYS,
42  CONF_DATA,
43  CONF_FROM,
44  CONF_TO,
45  DOMAIN,
46  LOGGER,
47  WEEKDAY_TO_CONF,
48 )
49 
50 STORAGE_VERSION = 1
51 STORAGE_VERSION_MINOR = 1
52 
53 
54 def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]:
55  """Validate the schedule of time ranges.
56 
57  Ensure they have no overlap and the end time is greater than the start time.
58  """
59  # Empty schedule is valid
60  if not schedule:
61  return schedule
62 
63  # Sort the schedule by start times
64  schedule = sorted(schedule, key=lambda time_range: time_range[CONF_FROM])
65 
66  # Check if the start time of the next event is before the end time of the previous event
67  previous_to = None
68  for time_range in schedule:
69  if time_range[CONF_FROM] >= time_range[CONF_TO]:
70  raise vol.Invalid(
71  f"Invalid time range, from {time_range[CONF_FROM]} is after"
72  f" {time_range[CONF_TO]}"
73  )
74 
75  # Check if the from time of the event is after the to time of the previous event
76  if previous_to is not None and previous_to > time_range[CONF_FROM]: # type: ignore[unreachable]
77  raise vol.Invalid("Overlapping times found in schedule")
78 
79  previous_to = time_range[CONF_TO]
80 
81  return schedule
82 
83 
84 def deserialize_to_time(value: Any) -> Any:
85  """Convert 24:00 and 24:00:00 to time.max."""
86  if not isinstance(value, str):
87  return cv.time(value)
88 
89  parts = value.split(":")
90  if len(parts) < 2:
91  return cv.time(value)
92  hour = int(parts[0])
93  minute = int(parts[1])
94 
95  if hour == 24 and minute == 0:
96  return time.max
97 
98  return cv.time(value)
99 
100 
101 def serialize_to_time(value: Any) -> Any:
102  """Convert time.max to 24:00:00."""
103  if value == time.max:
104  return "24:00:00"
105  return vol.Coerce(str)(value)
106 
107 
108 BASE_SCHEMA: VolDictType = {
109  vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)),
110  vol.Optional(CONF_ICON): cv.icon,
111 }
112 
113 # Extra data that the user can set on each time range
114 CUSTOM_DATA_SCHEMA = vol.Schema({str: vol.Any(bool, str, int, float)})
115 
116 TIME_RANGE_SCHEMA: VolDictType = {
117  vol.Required(CONF_FROM): cv.time,
118  vol.Required(CONF_TO): deserialize_to_time,
119  vol.Optional(CONF_DATA): CUSTOM_DATA_SCHEMA,
120 }
121 
122 # Serialize time in validated config
123 STORAGE_TIME_RANGE_SCHEMA = vol.Schema(
124  {
125  vol.Required(CONF_FROM): vol.Coerce(str),
126  vol.Required(CONF_TO): serialize_to_time,
127  vol.Optional(CONF_DATA): CUSTOM_DATA_SCHEMA,
128  }
129 )
130 
131 SCHEDULE_SCHEMA: VolDictType = {
132  vol.Optional(day, default=[]): vol.All(
133  cv.ensure_list, [TIME_RANGE_SCHEMA], valid_schedule
134  )
135  for day in CONF_ALL_DAYS
136 }
137 STORAGE_SCHEDULE_SCHEMA: VolDictType = {
138  vol.Optional(day, default=[]): vol.All(
139  cv.ensure_list, [TIME_RANGE_SCHEMA], valid_schedule, [STORAGE_TIME_RANGE_SCHEMA]
140  )
141  for day in CONF_ALL_DAYS
142 }
143 
144 # Validate YAML config
145 CONFIG_SCHEMA = vol.Schema(
146  {DOMAIN: cv.schema_with_slug_keys(vol.All(BASE_SCHEMA | SCHEDULE_SCHEMA))},
147  extra=vol.ALLOW_EXTRA,
148 )
149 # Validate storage config
150 STORAGE_SCHEMA = vol.Schema(
151  {vol.Required(CONF_ID): cv.string} | BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA
152 )
153 # Validate + transform entity config
154 ENTITY_SCHEMA = vol.Schema(
155  {vol.Required(CONF_ID): cv.string} | BASE_SCHEMA | SCHEDULE_SCHEMA
156 )
157 
158 
159 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
160  """Set up a schedule."""
161  component = EntityComponent[Schedule](LOGGER, DOMAIN, hass)
162 
163  id_manager = IDManager()
164 
165  yaml_collection = YamlCollection(LOGGER, id_manager)
166  sync_entity_lifecycle(hass, DOMAIN, DOMAIN, component, yaml_collection, Schedule)
167 
168  storage_collection = ScheduleStorageCollection(
169  Store(
170  hass,
171  key=DOMAIN,
172  version=STORAGE_VERSION,
173  minor_version=STORAGE_VERSION_MINOR,
174  ),
175  id_manager,
176  )
177  sync_entity_lifecycle(hass, DOMAIN, DOMAIN, component, storage_collection, Schedule)
178 
179  await yaml_collection.async_load(
180  [{CONF_ID: id_, **cfg} for id_, cfg in config.get(DOMAIN, {}).items()]
181  )
182  await storage_collection.async_load()
183 
185  storage_collection,
186  DOMAIN,
187  DOMAIN,
188  BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA,
189  BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA,
190  ).async_setup(hass)
191 
192  async def reload_service_handler(service_call: ServiceCall) -> None:
193  """Reload yaml entities."""
194  conf = await component.async_prepare_reload(skip_reset=True)
195  if conf is None:
196  conf = {DOMAIN: {}}
197  await yaml_collection.async_load(
198  [{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()]
199  )
200 
202  hass,
203  DOMAIN,
204  SERVICE_RELOAD,
205  reload_service_handler,
206  )
207 
208  return True
209 
210 
212  """Schedules stored in storage."""
213 
214  SCHEMA = vol.Schema(BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA)
215 
216  async def _process_create_data(self, data: dict) -> dict:
217  """Validate the config is valid."""
218  self.SCHEMASCHEMA(data)
219  return data
220 
221  @callback
222  def _get_suggested_id(self, info: dict) -> str:
223  """Suggest an ID based on the config."""
224  name: str = info[CONF_NAME]
225  return name
226 
227  async def _update_data(self, item: dict, update_data: dict) -> dict:
228  """Return a new updated data object."""
229  self.SCHEMASCHEMA(update_data)
230  return item | update_data
231 
232  async def _async_load_data(self) -> SerializedStorageCollection | None:
233  """Load the data."""
234  if data := await super()._async_load_data():
235  data["items"] = [STORAGE_SCHEMA(item) for item in data["items"]]
236  return data
237 
238 
240  """Schedule entity."""
241 
242  _entity_component_unrecorded_attributes = frozenset(
243  {ATTR_EDITABLE, ATTR_NEXT_EVENT}
244  )
245 
246  _attr_has_entity_name = True
247  _attr_should_poll = False
248  _attr_state: Literal["on", "off"]
249  _config: ConfigType
250  _next: datetime
251  _unsub_update: Callable[[], None] | None = None
252 
253  def __init__(self, config: ConfigType, editable: bool) -> None:
254  """Initialize a schedule."""
255  self._config_config = ENTITY_SCHEMA(config)
256  self._attr_capability_attributes_attr_capability_attributes = {ATTR_EDITABLE: editable}
257  self._attr_icon_attr_icon = self._config_config.get(CONF_ICON)
258  self._attr_name_attr_name = self._config_config[CONF_NAME]
259  self._attr_unique_id_attr_unique_id = self._config_config[CONF_ID]
260 
261  # Exclude any custom attributes that may be present on time ranges from recording.
262  self._unrecorded_attributes_unrecorded_attributes = self.all_custom_data_keysall_custom_data_keys()
263  self._Entity__combined_unrecorded_attributes_Entity__combined_unrecorded_attributes = (
264  self._entity_component_unrecorded_attributes_entity_component_unrecorded_attributes | self._unrecorded_attributes_unrecorded_attributes
265  )
266 
267  @classmethod
268  def from_storage(cls, config: ConfigType) -> Schedule:
269  """Return entity instance initialized from storage."""
270  return cls(config, editable=True)
271 
272  @classmethod
273  def from_yaml(cls, config: ConfigType) -> Schedule:
274  """Return entity instance initialized from yaml."""
275  schedule = cls(config, editable=False)
276  schedule.entity_id = f"{DOMAIN}.{config[CONF_ID]}"
277  return schedule
278 
279  async def async_update_config(self, config: ConfigType) -> None:
280  """Handle when the config is updated."""
281  self._config_config = ENTITY_SCHEMA(config)
282  self._attr_icon_attr_icon = config.get(CONF_ICON)
283  self._attr_name_attr_name = config[CONF_NAME]
284  self._clean_up_listener_clean_up_listener()
285  self._update_update()
286 
287  @callback
288  def _clean_up_listener(self) -> None:
289  """Remove the update timer."""
290  if self._unsub_update_unsub_update is not None:
291  self._unsub_update_unsub_update()
292  self._unsub_update_unsub_update = None
293 
294  async def async_added_to_hass(self) -> None:
295  """Run when entity about to be added to hass."""
296  self.async_on_removeasync_on_remove(self._clean_up_listener_clean_up_listener)
297  self._update_update()
298 
299  @callback
300  def _update(self, _: datetime | None = None) -> None:
301  """Update the states of the schedule."""
302  now = dt_util.now()
303  todays_schedule = self._config_config.get(WEEKDAY_TO_CONF[now.weekday()], [])
304 
305  # Determine current schedule state
306  for time_range in todays_schedule:
307  # The current time should be greater or equal to CONF_FROM.
308  if now.time() < time_range[CONF_FROM]:
309  continue
310  # The current time should be smaller (and not equal) to CONF_TO.
311  # Note that any time in the day is treated as smaller than time.max.
312  if now.time() < time_range[CONF_TO] or time_range[CONF_TO] == time.max:
313  self._attr_state_attr_state = STATE_ON
314  current_data = time_range.get(CONF_DATA)
315  break
316  else:
317  self._attr_state_attr_state = STATE_OFF
318  current_data = None
319 
320  # Find next event in the schedule, loop over each day (starting with
321  # the current day) until the next event has been found.
322  next_event = None
323  for day in range(8): # 8 because we need to search today's weekday next week
324  day_schedule = self._config_config.get(
325  WEEKDAY_TO_CONF[(now.weekday() + day) % 7], []
326  )
327  times = sorted(
328  itertools.chain(
329  *[
330  [time_range[CONF_FROM], time_range[CONF_TO]]
331  for time_range in day_schedule
332  ]
333  )
334  )
335 
336  if next_event := next(
337  (
338  possible_next_event
339  for timestamp in times
340  if (
341  possible_next_event := (
342  datetime.combine(now.date(), timestamp, tzinfo=now.tzinfo)
343  + timedelta(days=day)
344  if timestamp != time.max
345  # Special case for midnight of the following day.
346  else datetime.combine(now.date(), time(), tzinfo=now.tzinfo)
347  + timedelta(days=day + 1)
348  )
349  )
350  > now
351  ),
352  None,
353  ):
354  # We have found the next event in this day, stop searching.
355  break
356 
357  self._attr_extra_state_attributes_attr_extra_state_attributes = {
358  ATTR_NEXT_EVENT: next_event,
359  }
360 
361  if current_data:
362  # Add each key/value pair in the data to the entity's state attributes
363  self._attr_extra_state_attributes_attr_extra_state_attributes.update(current_data)
364 
365  self.async_write_ha_stateasync_write_ha_state()
366 
367  if next_event:
368  self._unsub_update_unsub_update = async_track_point_in_utc_time(
369  self.hasshass,
370  self._update_update,
371  next_event,
372  )
373 
374  def all_custom_data_keys(self) -> frozenset[str]:
375  """Return the set of all currently used custom data attribute keys."""
376  data_keys = set()
377 
378  for weekday in WEEKDAY_TO_CONF.values():
379  if not (weekday_config := self._config_config.get(weekday)):
380  continue # this weekday is not configured
381 
382  for time_range in weekday_config:
383  time_range_custom_data = time_range.get(CONF_DATA)
384 
385  if not time_range_custom_data or not isinstance(
386  time_range_custom_data, dict
387  ):
388  continue # this time range has no custom data, or it is not a dict
389 
390  data_keys.update(time_range_custom_data.keys())
391 
392  return frozenset(data_keys)
dict _update_data(self, dict item, dict update_data)
Definition: __init__.py:227
SerializedStorageCollection|None _async_load_data(self)
Definition: __init__.py:232
Schedule from_yaml(cls, ConfigType config)
Definition: __init__.py:273
Schedule from_storage(cls, ConfigType config)
Definition: __init__.py:268
None async_update_config(self, ConfigType config)
Definition: __init__.py:279
None __init__(self, ConfigType config, bool editable)
Definition: __init__.py:253
frozenset[str] all_custom_data_keys(self)
Definition: __init__.py:374
None _update(self, datetime|None _=None)
Definition: __init__.py:300
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
Any serialize_to_time(Any value)
Definition: __init__.py:101
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:159
Any deserialize_to_time(Any value)
Definition: __init__.py:84
list[dict[str, str]] valid_schedule(list[dict[str, str]] schedule)
Definition: __init__.py:54
None sync_entity_lifecycle(HomeAssistant hass, str domain, str platform, EntityComponent[_EntityT] entity_component, StorageCollection|YamlCollection collection, type[CollectionEntity] entity_class)
Definition: collection.py:533
bool time(HomeAssistant hass, dt_time|str|None before=None, dt_time|str|None after=None, str|Container[str]|None weekday=None)
Definition: condition.py:802
CALLBACK_TYPE async_track_point_in_utc_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:1542
None async_register_admin_service(HomeAssistant hass, str domain, str service, Callable[[ServiceCall], Awaitable[None]|None] service_func, VolSchemaType schema=vol.Schema({}, extra=vol.PREVENT_EXTRA))
Definition: service.py:1121