Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support to select a date and/or a time."""
2 
3 from __future__ import annotations
4 
5 import datetime as py_datetime
6 import logging
7 from typing import Any, Self
8 
9 import voluptuous as vol
10 
11 from homeassistant.const import (
12  ATTR_DATE,
13  ATTR_EDITABLE,
14  ATTR_TIME,
15  CONF_ICON,
16  CONF_ID,
17  CONF_NAME,
18  SERVICE_RELOAD,
19 )
20 from homeassistant.core import HomeAssistant, ServiceCall, callback
21 from homeassistant.helpers import collection
23 from homeassistant.helpers.entity_component import EntityComponent
24 from homeassistant.helpers.restore_state import RestoreEntity
26 from homeassistant.helpers.storage import Store
27 from homeassistant.helpers.typing import ConfigType, VolDictType
28 from homeassistant.util import dt as dt_util
29 
30 _LOGGER = logging.getLogger(__name__)
31 
32 DOMAIN = "input_datetime"
33 
34 CONF_HAS_DATE = "has_date"
35 CONF_HAS_TIME = "has_time"
36 CONF_INITIAL = "initial"
37 
38 DEFAULT_TIME = py_datetime.time(0, 0, 0)
39 
40 ATTR_DATETIME = "datetime"
41 ATTR_TIMESTAMP = "timestamp"
42 
43 FMT_DATE = "%Y-%m-%d"
44 FMT_TIME = "%H:%M:%S"
45 FMT_DATETIME = f"{FMT_DATE} {FMT_TIME}"
46 
47 
49  """Validate set_datetime service attributes."""
50  has_date_or_time_attr = any(key in config for key in (ATTR_DATE, ATTR_TIME))
51  if (
52  sum([has_date_or_time_attr, ATTR_DATETIME in config, ATTR_TIMESTAMP in config])
53  > 1
54  ):
55  raise vol.Invalid(f"Cannot use together: {', '.join(config.keys())}")
56  return config
57 
58 
59 STORAGE_KEY = DOMAIN
60 STORAGE_VERSION = 1
61 
62 STORAGE_FIELDS: VolDictType = {
63  vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)),
64  vol.Optional(CONF_HAS_DATE, default=False): cv.boolean,
65  vol.Optional(CONF_HAS_TIME, default=False): cv.boolean,
66  vol.Optional(CONF_ICON): cv.icon,
67  vol.Optional(CONF_INITIAL): cv.string,
68 }
69 
70 
71 def has_date_or_time(conf):
72  """Check at least date or time is true."""
73  if conf[CONF_HAS_DATE] or conf[CONF_HAS_TIME]:
74  return conf
75 
76  raise vol.Invalid("Entity needs at least a date or a time")
77 
78 
79 def valid_initial(conf: dict[str, Any]) -> dict[str, Any]:
80  """Check the initial value is valid."""
81  if not (conf.get(CONF_INITIAL)):
82  return conf
83 
84  # Ensure we can parse the initial value, raise vol.Invalid on failure
86  return conf
87 
88 
89 def parse_initial_datetime(conf: dict[str, Any]) -> py_datetime.datetime:
90  """Check the initial value is valid."""
91  initial: str = conf[CONF_INITIAL]
92 
93  if conf[CONF_HAS_DATE] and conf[CONF_HAS_TIME]:
94  if (datetime := dt_util.parse_datetime(initial)) is not None:
95  return datetime
96  raise vol.Invalid(f"Initial value '{initial}' can't be parsed as a datetime")
97 
98  if conf[CONF_HAS_DATE]:
99  if (date := dt_util.parse_date(initial)) is not None:
100  return py_datetime.datetime.combine(date, DEFAULT_TIME)
101  raise vol.Invalid(f"Initial value '{initial}' can't be parsed as a date")
102 
103  if (time := dt_util.parse_time(initial)) is not None:
104  return py_datetime.datetime.combine(py_datetime.date.today(), time)
105  raise vol.Invalid(f"Initial value '{initial}' can't be parsed as a time")
106 
107 
108 CONFIG_SCHEMA = vol.Schema(
109  {
110  DOMAIN: cv.schema_with_slug_keys(
111  vol.All(
112  {
113  vol.Optional(CONF_NAME): cv.string,
114  vol.Optional(CONF_HAS_DATE, default=False): cv.boolean,
115  vol.Optional(CONF_HAS_TIME, default=False): cv.boolean,
116  vol.Optional(CONF_ICON): cv.icon,
117  vol.Optional(CONF_INITIAL): cv.string,
118  },
119  has_date_or_time,
120  valid_initial,
121  )
122  )
123  },
124  extra=vol.ALLOW_EXTRA,
125 )
126 RELOAD_SERVICE_SCHEMA = vol.Schema({})
127 
128 
129 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
130  """Set up an input datetime."""
131  component = EntityComponent[InputDatetime](_LOGGER, DOMAIN, hass)
132 
133  id_manager = collection.IDManager()
134 
135  yaml_collection = collection.YamlCollection(
136  logging.getLogger(f"{__name__}.yaml_collection"), id_manager
137  )
138  collection.sync_entity_lifecycle(
139  hass, DOMAIN, DOMAIN, component, yaml_collection, InputDatetime
140  )
141 
142  storage_collection = DateTimeStorageCollection(
143  Store(hass, STORAGE_VERSION, STORAGE_KEY),
144  id_manager,
145  )
146  collection.sync_entity_lifecycle(
147  hass, DOMAIN, DOMAIN, component, storage_collection, InputDatetime
148  )
149 
150  await yaml_collection.async_load(
151  [{CONF_ID: id_, **cfg} for id_, cfg in config.get(DOMAIN, {}).items()]
152  )
153  await storage_collection.async_load()
154 
155  collection.DictStorageCollectionWebsocket(
156  storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS
157  ).async_setup(hass)
158 
159  async def reload_service_handler(service_call: ServiceCall) -> None:
160  """Reload yaml entities."""
161  conf = await component.async_prepare_reload(skip_reset=True)
162  if conf is None:
163  conf = {DOMAIN: {}}
164  await yaml_collection.async_load(
165  [{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()]
166  )
167 
169  hass,
170  DOMAIN,
171  SERVICE_RELOAD,
172  reload_service_handler,
173  schema=RELOAD_SERVICE_SCHEMA,
174  )
175 
176  component.async_register_entity_service(
177  "set_datetime",
178  vol.All(
179  cv.make_entity_service_schema(
180  {
181  vol.Optional(ATTR_DATE): cv.date,
182  vol.Optional(ATTR_TIME): cv.time,
183  vol.Optional(ATTR_DATETIME): cv.datetime,
184  vol.Optional(ATTR_TIMESTAMP): vol.Coerce(float),
185  },
186  ),
187  cv.has_at_least_one_key(
188  ATTR_DATE, ATTR_TIME, ATTR_DATETIME, ATTR_TIMESTAMP
189  ),
190  validate_set_datetime_attrs,
191  ),
192  "async_set_datetime",
193  )
194 
195  return True
196 
197 
198 class DateTimeStorageCollection(collection.DictStorageCollection):
199  """Input storage based collection."""
200 
201  CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, has_date_or_time))
202 
203  async def _process_create_data(self, data: dict) -> dict:
204  """Validate the config is valid."""
205  return self.CREATE_UPDATE_SCHEMACREATE_UPDATE_SCHEMA(data)
206 
207  @callback
208  def _get_suggested_id(self, info: dict) -> str:
209  """Suggest an ID based on the config."""
210  return info[CONF_NAME]
211 
212  async def _update_data(self, item: dict, update_data: dict) -> dict:
213  """Return a new updated data object."""
214  update_data = self.CREATE_UPDATE_SCHEMACREATE_UPDATE_SCHEMA(update_data)
215  return {CONF_ID: item[CONF_ID]} | update_data
216 
217 
218 class InputDatetime(collection.CollectionEntity, RestoreEntity):
219  """Representation of a datetime input."""
220 
221  _unrecorded_attributes = frozenset({ATTR_EDITABLE, CONF_HAS_DATE, CONF_HAS_TIME})
222 
223  _attr_should_poll = False
224  editable: bool
225 
226  def __init__(self, config: ConfigType) -> None:
227  """Initialize a select input."""
228  self._config_config = config
229  self._current_datetime_current_datetime = None
230 
231  if not config.get(CONF_INITIAL):
232  return
233 
234  current_datetime = parse_initial_datetime(config)
235 
236  # If the user passed in an initial value with a timezone, convert it to right tz
237  if current_datetime.tzinfo is not None:
238  self._current_datetime_current_datetime = current_datetime.astimezone(
239  dt_util.get_default_time_zone()
240  )
241  else:
242  self._current_datetime_current_datetime = current_datetime.replace(
243  tzinfo=dt_util.get_default_time_zone()
244  )
245 
246  @classmethod
247  def from_storage(cls, config: ConfigType) -> Self:
248  """Return entity instance initialized from storage."""
249  input_dt = cls(config)
250  input_dt.editable = True
251  return input_dt
252 
253  @classmethod
254  def from_yaml(cls, config: ConfigType) -> Self:
255  """Return entity instance initialized from yaml."""
256  input_dt = cls(config)
257  input_dt.entity_id = f"{DOMAIN}.{config[CONF_ID]}"
258  input_dt.editable = False
259  return input_dt
260 
261  async def async_added_to_hass(self):
262  """Run when entity about to be added."""
263  await super().async_added_to_hass()
264 
265  # Priority 1: Initial value
266  if self.statestatestate is not None:
267  return
268 
269  default_value = py_datetime.datetime.today().strftime(f"{FMT_DATE} 00:00:00")
270 
271  # Priority 2: Old state
272  if (old_state := await self.async_get_last_stateasync_get_last_state()) is None:
273  self._current_datetime_current_datetime = dt_util.parse_datetime(default_value)
274  return
275 
276  if self.has_datehas_date and self.has_timehas_time:
277  date_time = dt_util.parse_datetime(old_state.state)
278  if date_time is None:
279  current_datetime = dt_util.parse_datetime(default_value)
280  else:
281  current_datetime = date_time
282 
283  elif self.has_datehas_date:
284  if (date := dt_util.parse_date(old_state.state)) is None:
285  current_datetime = dt_util.parse_datetime(default_value)
286  else:
287  current_datetime = py_datetime.datetime.combine(date, DEFAULT_TIME)
288 
289  elif (time := dt_util.parse_time(old_state.state)) is None:
290  current_datetime = dt_util.parse_datetime(default_value)
291  else:
292  current_datetime = py_datetime.datetime.combine(
293  py_datetime.date.today(), time
294  )
295 
296  self._current_datetime_current_datetime = current_datetime.replace(
297  tzinfo=dt_util.get_default_time_zone()
298  )
299 
300  @property
301  def name(self):
302  """Return the name of the select input."""
303  return self._config_config.get(CONF_NAME)
304 
305  @property
306  def has_date(self) -> bool:
307  """Return True if entity has date."""
308  return self._config_config[CONF_HAS_DATE]
309 
310  @property
311  def has_time(self) -> bool:
312  """Return True if entity has time."""
313  return self._config_config[CONF_HAS_TIME]
314 
315  @property
316  def icon(self):
317  """Return the icon to be used for this entity."""
318  return self._config_config.get(CONF_ICON)
319 
320  @property
321  def state(self):
322  """Return the state of the component."""
323  if self._current_datetime_current_datetime is None:
324  return None
325 
326  if self.has_datehas_date and self.has_timehas_time:
327  return self._current_datetime_current_datetime.strftime(FMT_DATETIME)
328 
329  if self.has_datehas_date:
330  return self._current_datetime_current_datetime.strftime(FMT_DATE)
331 
332  return self._current_datetime_current_datetime.strftime(FMT_TIME)
333 
334  @property
335  def capability_attributes(self) -> dict[str, Any]:
336  """Return the capability attributes."""
337  return {
338  CONF_HAS_DATE: self.has_datehas_date,
339  CONF_HAS_TIME: self.has_timehas_time,
340  }
341 
342  @property
344  """Return the state attributes."""
345  attrs = {
346  ATTR_EDITABLE: self.editable,
347  }
348 
349  if self._current_datetime_current_datetime is None:
350  return attrs
351 
352  if self.has_datehas_date and self._current_datetime_current_datetime is not None:
353  attrs["year"] = self._current_datetime_current_datetime.year
354  attrs["month"] = self._current_datetime_current_datetime.month
355  attrs["day"] = self._current_datetime_current_datetime.day
356 
357  if self.has_timehas_time and self._current_datetime_current_datetime is not None:
358  attrs["hour"] = self._current_datetime_current_datetime.hour
359  attrs["minute"] = self._current_datetime_current_datetime.minute
360  attrs["second"] = self._current_datetime_current_datetime.second
361 
362  if not self.has_datehas_date:
363  attrs["timestamp"] = (
364  self._current_datetime_current_datetime.hour * 3600
365  + self._current_datetime_current_datetime.minute * 60
366  + self._current_datetime_current_datetime.second
367  )
368 
369  elif not self.has_timehas_time:
370  extended = py_datetime.datetime.combine(
371  self._current_datetime_current_datetime, py_datetime.time(0, 0)
372  )
373  attrs["timestamp"] = extended.timestamp()
374 
375  else:
376  attrs["timestamp"] = self._current_datetime_current_datetime.timestamp()
377 
378  return attrs
379 
380  @property
381  def unique_id(self) -> str | None:
382  """Return unique id of the entity."""
383  return self._config_config[CONF_ID]
384 
385  @callback
386  def async_set_datetime(self, date=None, time=None, datetime=None, timestamp=None):
387  """Set a new date / time."""
388  if timestamp:
389  datetime = dt_util.as_local(dt_util.utc_from_timestamp(timestamp))
390 
391  if datetime:
392  date = datetime.date()
393  time = datetime.time()
394 
395  if not self.has_datehas_date:
396  date = None
397 
398  if not self.has_timehas_time:
399  time = None
400 
401  if not date and not time:
402  raise vol.Invalid("Nothing to set")
403 
404  if not date:
405  date = self._current_datetime_current_datetime.date()
406 
407  if not time:
408  time = self._current_datetime_current_datetime.time()
409 
410  self._current_datetime_current_datetime = py_datetime.datetime.combine(
411  date, time, dt_util.get_default_time_zone()
412  )
413  self.async_write_ha_stateasync_write_ha_state()
414 
415  async def async_update_config(self, config: ConfigType) -> None:
416  """Handle when the config is updated."""
417  self._config_config = config
418  self.async_write_ha_stateasync_write_ha_state()
dict _update_data(self, dict item, dict update_data)
Definition: __init__.py:212
def async_set_datetime(self, date=None, time=None, datetime=None, timestamp=None)
Definition: __init__.py:386
None async_update_config(self, ConfigType config)
Definition: __init__.py:415
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
dict[str, Any] valid_initial(dict[str, Any] conf)
Definition: __init__.py:79
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:129
py_datetime.datetime parse_initial_datetime(dict[str, Any] conf)
Definition: __init__.py:89
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
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