Home Assistant Unofficial Reference 2024.12.1
binary_sensor.py
Go to the documentation of this file.
1 """Support for representing current time of the day as binary sensors."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from datetime import datetime, time, timedelta
7 import logging
8 from typing import Any, Literal, TypeGuard
9 
10 import voluptuous as vol
11 
13  PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA,
14  BinarySensorEntity,
15 )
16 from homeassistant.config_entries import ConfigEntry
17 from homeassistant.const import (
18  CONF_AFTER,
19  CONF_BEFORE,
20  CONF_NAME,
21  CONF_UNIQUE_ID,
22  SUN_EVENT_SUNRISE,
23  SUN_EVENT_SUNSET,
24 )
25 from homeassistant.core import HomeAssistant, callback
26 from homeassistant.helpers import config_validation as cv, event
27 from homeassistant.helpers.entity_platform import AddEntitiesCallback
28 from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next
29 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
30 from homeassistant.util import dt as dt_util
31 
32 from .const import (
33  CONF_AFTER_OFFSET,
34  CONF_AFTER_TIME,
35  CONF_BEFORE_OFFSET,
36  CONF_BEFORE_TIME,
37 )
38 
39 type SunEventType = Literal["sunrise", "sunset"]
40 
41 _LOGGER = logging.getLogger(__name__)
42 
43 ATTR_AFTER = "after"
44 ATTR_BEFORE = "before"
45 ATTR_NEXT_UPDATE = "next_update"
46 
47 PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
48  {
49  vol.Required(CONF_AFTER): vol.Any(cv.time, vol.All(vol.Lower, cv.sun_event)),
50  vol.Required(CONF_BEFORE): vol.Any(cv.time, vol.All(vol.Lower, cv.sun_event)),
51  vol.Required(CONF_NAME): cv.string,
52  vol.Optional(CONF_AFTER_OFFSET, default=timedelta(0)): cv.time_period,
53  vol.Optional(CONF_BEFORE_OFFSET, default=timedelta(0)): cv.time_period,
54  vol.Optional(CONF_UNIQUE_ID): cv.string,
55  }
56 )
57 
58 
60  hass: HomeAssistant,
61  config_entry: ConfigEntry,
62  async_add_entities: AddEntitiesCallback,
63 ) -> None:
64  """Initialize Times of the Day config entry."""
65  if hass.config.time_zone is None:
66  _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable]
67  return
68 
69  after = cv.time(config_entry.options[CONF_AFTER_TIME])
70  after_offset = timedelta(0)
71  before = cv.time(config_entry.options[CONF_BEFORE_TIME])
72  before_offset = timedelta(0)
73  name = config_entry.title
74  unique_id = config_entry.entry_id
75 
77  [TodSensor(name, after, after_offset, before, before_offset, unique_id)]
78  )
79 
80 
82  hass: HomeAssistant,
83  config: ConfigType,
84  async_add_entities: AddEntitiesCallback,
85  discovery_info: DiscoveryInfoType | None = None,
86 ) -> None:
87  """Set up the ToD sensors."""
88  if hass.config.time_zone is None:
89  _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable]
90  return
91 
92  after = config[CONF_AFTER]
93  after_offset = config[CONF_AFTER_OFFSET]
94  before = config[CONF_BEFORE]
95  before_offset = config[CONF_BEFORE_OFFSET]
96  name = config[CONF_NAME]
97  unique_id = config.get(CONF_UNIQUE_ID)
98  sensor = TodSensor(name, after, after_offset, before, before_offset, unique_id)
99 
100  async_add_entities([sensor])
101 
102 
103 def _is_sun_event(sun_event: time | SunEventType) -> TypeGuard[SunEventType]:
104  """Return true if event is sun event not time."""
105  return sun_event in (SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET)
106 
107 
109  """Time of the Day Sensor."""
110 
111  _attr_should_poll = False
112  _time_before: datetime
113  _time_after: datetime
114  _next_update: datetime
115 
116  def __init__(
117  self,
118  name: str,
119  after: time,
120  after_offset: timedelta,
121  before: time,
122  before_offset: timedelta,
123  unique_id: str | None,
124  ) -> None:
125  """Init the ToD Sensor..."""
126  self._attr_unique_id_attr_unique_id = unique_id
127  self._attr_name_attr_name = name
128  self._after_offset_after_offset = after_offset
129  self._before_offset_before_offset = before_offset
130  self._before_before = before
131  self._after_after = after
132  self._unsub_update_unsub_update: Callable[[], None] | None = None
133 
134  @property
135  def is_on(self) -> bool:
136  """Return True is sensor is on."""
137  if self._time_after_time_after < self._time_before_time_before:
138  return self._time_after_time_after <= dt_util.utcnow() < self._time_before_time_before
139  return False
140 
141  @property
142  def extra_state_attributes(self) -> dict[str, Any] | None:
143  """Return the state attributes of the sensor."""
144  if time_zone := dt_util.get_default_time_zone():
145  return {
146  ATTR_AFTER: self._time_after_time_after.astimezone(time_zone).isoformat(),
147  ATTR_BEFORE: self._time_before_time_before.astimezone(time_zone).isoformat(),
148  ATTR_NEXT_UPDATE: self._next_update_next_update.astimezone(time_zone).isoformat(),
149  }
150  return None
151 
152  def _naive_time_to_utc_datetime(self, naive_time: time) -> datetime:
153  """Convert naive time from config to utc_datetime with current day."""
154  # get the current local date from utc time
155  current_local_date = (
156  dt_util.utcnow().astimezone(dt_util.get_default_time_zone()).date()
157  )
158  # calculate utc datetime corresponding to local time
159  return dt_util.as_utc(datetime.combine(current_local_date, naive_time))
160 
161  def _calculate_boundary_time(self) -> None:
162  """Calculate internal absolute time boundaries."""
163  nowutc = dt_util.utcnow()
164  # If after value is a sun event instead of absolute time
165  if _is_sun_event(self._after_after):
166  # Calculate the today's event utc time or
167  # if not available take next
168  after_event_date = get_astral_event_date(
169  self.hasshass, self._after_after, nowutc
170  ) or get_astral_event_next(self.hasshass, self._after_after, nowutc)
171  else:
172  # Convert local time provided to UTC today
173  # datetime.combine(date, time, tzinfo) is not supported
174  # in python 3.5. The self._after is provided
175  # with hass configured TZ not system wide
176  after_event_date = self._naive_time_to_utc_datetime_naive_time_to_utc_datetime(self._after_after)
177 
178  self._time_after_time_after = after_event_date
179 
180  # If before value is a sun event instead of absolute time
181  if _is_sun_event(self._before_before):
182  # Calculate the today's event utc time or if not available take
183  # next
184  before_event_date = get_astral_event_date(
185  self.hasshass, self._before_before, nowutc
186  ) or get_astral_event_next(self.hasshass, self._before_before, nowutc)
187  # Before is earlier than after
188  if before_event_date < after_event_date:
189  # Take next day for before
190  before_event_date = get_astral_event_next(
191  self.hasshass, self._before_before, after_event_date
192  )
193  else:
194  # Convert local time provided to UTC today, see above
195  before_event_date = self._naive_time_to_utc_datetime_naive_time_to_utc_datetime(self._before_before)
196 
197  # It is safe to add timedelta days=1 to UTC as there is no DST
198  if before_event_date < after_event_date + self._after_offset_after_offset:
199  before_event_date += timedelta(days=1)
200 
201  self._time_before_time_before = before_event_date
202 
203  # We are calculating the _time_after value assuming that it will happen today
204  # But that is not always true, e.g. after 23:00, before 12:00 and now is 10:00
205  # If _time_before and _time_after are ahead of nowutc:
206  # _time_before is set to 12:00 next day
207  # _time_after is set to 23:00 today
208  # nowutc is set to 10:00 today
209 
210  if (
211  not _is_sun_event(self._after_after)
212  and self._time_after_time_after > nowutc
213  and self._time_before_time_before > nowutc + timedelta(days=1)
214  ):
215  # remove one day from _time_before and _time_after
216  self._time_after_time_after -= timedelta(days=1)
217  self._time_before_time_before -= timedelta(days=1)
218 
219  # Add offset to utc boundaries according to the configuration
220  self._time_after_time_after += self._after_offset_after_offset
221  self._time_before_time_before += self._before_offset_before_offset
222 
223  def _add_one_dst_aware_day(self, a_date: datetime, target_time: time) -> datetime:
224  """Add 24 hours (1 day) but account for DST."""
225  tentative_new_date = a_date + timedelta(days=1)
226  tentative_new_date = dt_util.as_local(tentative_new_date)
227  tentative_new_date = tentative_new_date.replace(
228  hour=target_time.hour, minute=target_time.minute
229  )
230  # The following call addresses missing time during DST jumps
231  return dt_util.find_next_time_expression_time(
232  tentative_new_date,
233  dt_util.parse_time_expression("*", 0, 59),
234  dt_util.parse_time_expression("*", 0, 59),
235  dt_util.parse_time_expression("*", 0, 23),
236  )
237 
238  def _turn_to_next_day(self) -> None:
239  """Turn to to the next day."""
240  if _is_sun_event(self._after_after):
241  self._time_after_time_after = get_astral_event_next(
242  self.hasshass, self._after_after, self._time_after_time_after - self._after_offset_after_offset
243  )
244  self._time_after_time_after += self._after_offset_after_offset
245  else:
246  # Offset is already there
247  self._time_after_time_after = self._add_one_dst_aware_day_add_one_dst_aware_day(
248  self._time_after_time_after, self._after_after
249  )
250 
251  if _is_sun_event(self._before_before):
252  self._time_before_time_before = get_astral_event_next(
253  self.hasshass, self._before_before, self._time_before_time_before - self._before_offset_before_offset
254  )
255  self._time_before_time_before += self._before_offset_before_offset
256  else:
257  # Offset is already there
258  self._time_before_time_before = self._add_one_dst_aware_day_add_one_dst_aware_day(
259  self._time_before_time_before, self._before_before
260  )
261 
262  async def async_added_to_hass(self) -> None:
263  """Call when entity about to be added to Home Assistant."""
264  self._calculate_boundary_time_calculate_boundary_time()
265  self._calculate_next_update_calculate_next_update()
266 
267  @callback
268  def _clean_up_listener() -> None:
269  if self._unsub_update_unsub_update is not None:
270  self._unsub_update_unsub_update()
271  self._unsub_update_unsub_update = None
272 
273  self.async_on_removeasync_on_remove(_clean_up_listener)
274 
275  self._unsub_update_unsub_update = event.async_track_point_in_utc_time(
276  self.hasshass, self._point_in_time_listener_point_in_time_listener, self._next_update_next_update
277  )
278 
279  def _calculate_next_update(self) -> None:
280  """Datetime when the next update to the state."""
281  now = dt_util.utcnow()
282  if now < self._time_after_time_after:
283  self._next_update_next_update = self._time_after_time_after
284  return
285  if now < self._time_before_time_before:
286  self._next_update_next_update = self._time_before_time_before
287  return
288  self._turn_to_next_day_turn_to_next_day()
289  self._next_update_next_update = self._time_after_time_after
290 
291  @callback
292  def _point_in_time_listener(self, now: datetime) -> None:
293  """Run when the state of the sensor should be updated."""
294  self._calculate_next_update_calculate_next_update()
295  self.async_write_ha_stateasync_write_ha_state()
296 
297  self._unsub_update_unsub_update = event.async_track_point_in_utc_time(
298  self.hasshass, self._point_in_time_listener_point_in_time_listener, self._next_update_next_update
299  )
datetime _add_one_dst_aware_day(self, datetime a_date, time target_time)
None __init__(self, str name, time after, timedelta after_offset, time before, timedelta before_offset, str|None unique_id)
datetime _naive_time_to_utc_datetime(self, time naive_time)
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
TypeGuard[SunEventType] _is_sun_event(time|SunEventType sun_event)
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
datetime.datetime|None get_astral_event_date(HomeAssistant hass, str event, datetime.date|datetime.datetime|None date=None)
Definition: sun.py:117
datetime.datetime get_astral_event_next(HomeAssistant hass, str event, datetime.datetime|None utc_point_in_time=None, datetime.timedelta|None offset=None)
Definition: sun.py:60