Home Assistant Unofficial Reference 2024.12.1
entity.py
Go to the documentation of this file.
1 """Support for functionality to keep track of the sun."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime, timedelta
6 import logging
7 from typing import Any
8 
9 from astral.location import Elevation, Location
10 
11 from homeassistant.config_entries import ConfigEntry
12 from homeassistant.const import (
13  EVENT_CORE_CONFIG_UPDATE,
14  SUN_EVENT_SUNRISE,
15  SUN_EVENT_SUNSET,
16 )
17 from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
18 from homeassistant.helpers import event
19 from homeassistant.helpers.dispatcher import async_dispatcher_send
20 from homeassistant.helpers.entity import Entity
21 from homeassistant.helpers.sun import (
22  get_astral_location,
23  get_location_astral_event_next,
24 )
25 from homeassistant.util import dt as dt_util
26 
27 from .const import (
28  SIGNAL_EVENTS_CHANGED,
29  SIGNAL_POSITION_CHANGED,
30  STATE_ABOVE_HORIZON,
31  STATE_BELOW_HORIZON,
32 )
33 
34 type SunConfigEntry = ConfigEntry[Sun]
35 
36 _LOGGER = logging.getLogger(__name__)
37 
38 ENTITY_ID = "sun.sun"
39 
40 STATE_ATTR_AZIMUTH = "azimuth"
41 STATE_ATTR_ELEVATION = "elevation"
42 STATE_ATTR_RISING = "rising"
43 STATE_ATTR_NEXT_DAWN = "next_dawn"
44 STATE_ATTR_NEXT_DUSK = "next_dusk"
45 STATE_ATTR_NEXT_MIDNIGHT = "next_midnight"
46 STATE_ATTR_NEXT_NOON = "next_noon"
47 STATE_ATTR_NEXT_RISING = "next_rising"
48 STATE_ATTR_NEXT_SETTING = "next_setting"
49 
50 # The algorithm used here is somewhat complicated. It aims to cut down
51 # the number of sensor updates over the day. It's documented best in
52 # the PR for the change, see the Discussion section of:
53 # https://github.com/home-assistant/core/pull/23832
54 
55 
56 # As documented in wikipedia: https://en.wikipedia.org/wiki/Twilight
57 # sun is:
58 # < -18° of horizon - all stars visible
59 PHASE_NIGHT = "night"
60 # 18°-12° - some stars not visible
61 PHASE_ASTRONOMICAL_TWILIGHT = "astronomical_twilight"
62 # 12°-6° - horizon visible
63 PHASE_NAUTICAL_TWILIGHT = "nautical_twilight"
64 # 6°-0° - objects visible
65 PHASE_TWILIGHT = "twilight"
66 # 0°-10° above horizon, sun low on horizon
67 PHASE_SMALL_DAY = "small_day"
68 # > 10° above horizon
69 PHASE_DAY = "day"
70 
71 # 4 mins is one degree of arc change of the sun on its circle.
72 # During the night and the middle of the day we don't update
73 # that much since it's not important.
74 _PHASE_UPDATES = {
75  PHASE_NIGHT: timedelta(minutes=4 * 5),
76  PHASE_ASTRONOMICAL_TWILIGHT: timedelta(minutes=4 * 2),
77  PHASE_NAUTICAL_TWILIGHT: timedelta(minutes=4 * 2),
78  PHASE_TWILIGHT: timedelta(minutes=4),
79  PHASE_SMALL_DAY: timedelta(minutes=2),
80  PHASE_DAY: timedelta(minutes=4),
81 }
82 
83 
84 class Sun(Entity):
85  """Representation of the Sun."""
86 
87  _unrecorded_attributes = frozenset(
88  {
89  STATE_ATTR_AZIMUTH,
90  STATE_ATTR_ELEVATION,
91  STATE_ATTR_RISING,
92  STATE_ATTR_NEXT_DAWN,
93  STATE_ATTR_NEXT_DUSK,
94  STATE_ATTR_NEXT_MIDNIGHT,
95  STATE_ATTR_NEXT_NOON,
96  STATE_ATTR_NEXT_RISING,
97  STATE_ATTR_NEXT_SETTING,
98  }
99  )
100 
101  _attr_name = "Sun"
102  entity_id = ENTITY_ID
103  # This entity is legacy and does not have a platform.
104  # We can't fix this easily without breaking changes.
105  _no_platform_reported = True
106 
107  location: Location
108  elevation: Elevation
109  next_rising: datetime
110  next_setting: datetime
111  next_dawn: datetime
112  next_dusk: datetime
113  next_midnight: datetime
114  next_noon: datetime
115  solar_elevation: float
116  solar_azimuth: float
117  rising: bool
118  _next_change: datetime
119 
120  def __init__(self, hass: HomeAssistant) -> None:
121  """Initialize the sun."""
122  self.hasshasshass = hass
123  self.phasephase: str | None = None
124 
125  # This is normally done by async_internal_added_to_hass which is not called
126  # for sun because sun has no platform
127  self._state_info_state_info_state_info = {
128  "unrecorded_attributes": self._Entity__combined_unrecorded_attributes # type: ignore[attr-defined]
129  }
130 
131  self._config_listener_config_listener: CALLBACK_TYPE | None = None
132  self._update_events_listener_update_events_listener: CALLBACK_TYPE | None = None
133  self._update_sun_position_listener_update_sun_position_listener: CALLBACK_TYPE | None = None
134  self._config_listener_config_listener = self.hasshasshass.bus.async_listen(
135  EVENT_CORE_CONFIG_UPDATE, self.update_locationupdate_location
136  )
137  self.update_locationupdate_location(initial=True)
138 
139  @callback
140  def update_location(self, _: Event | None = None, initial: bool = False) -> None:
141  """Update location."""
142  location, elevation = get_astral_location(self.hasshasshass)
143  if not initial and location == self.locationlocation:
144  return
145  self.locationlocation = location
146  self.elevationelevation = elevation
147  if self._update_events_listener_update_events_listener:
148  self._update_events_listener_update_events_listener()
149  self.update_eventsupdate_events()
150 
151  @callback
152  def remove_listeners(self) -> None:
153  """Remove listeners."""
154  if self._config_listener_config_listener:
155  self._config_listener_config_listener()
156  if self._update_events_listener_update_events_listener:
157  self._update_events_listener_update_events_listener()
158  if self._update_sun_position_listener_update_sun_position_listener:
159  self._update_sun_position_listener_update_sun_position_listener()
160 
161  @property
162  def state(self) -> str:
163  """Return the state of the sun."""
164  # 0.8333 is the same value as astral uses
165  if self.solar_elevationsolar_elevation > -0.833:
166  return STATE_ABOVE_HORIZON
167 
168  return STATE_BELOW_HORIZON
169 
170  @property
171  def extra_state_attributes(self) -> dict[str, Any]:
172  """Return the state attributes of the sun."""
173  return {
174  STATE_ATTR_NEXT_DAWN: self.next_dawnnext_dawn.isoformat(),
175  STATE_ATTR_NEXT_DUSK: self.next_dusknext_dusk.isoformat(),
176  STATE_ATTR_NEXT_MIDNIGHT: self.next_midnightnext_midnight.isoformat(),
177  STATE_ATTR_NEXT_NOON: self.next_noonnext_noon.isoformat(),
178  STATE_ATTR_NEXT_RISING: self.next_risingnext_rising.isoformat(),
179  STATE_ATTR_NEXT_SETTING: self.next_settingnext_setting.isoformat(),
180  STATE_ATTR_ELEVATION: self.solar_elevationsolar_elevation,
181  STATE_ATTR_AZIMUTH: self.solar_azimuthsolar_azimuth,
182  STATE_ATTR_RISING: self.risingrising,
183  }
184 
186  self, utc_point_in_time: datetime, sun_event: str, before: str | None
187  ) -> datetime:
189  self.locationlocation, self.elevationelevation, sun_event, utc_point_in_time
190  )
191  if next_utc < self._next_change_next_change:
192  self._next_change_next_change = next_utc
193  self.phasephase = before
194  return next_utc
195 
196  @callback
197  def update_events(self, now: datetime | None = None) -> None:
198  """Update the attributes containing solar events."""
199  # Grab current time in case system clock changed since last time we ran.
200  utc_point_in_time = dt_util.utcnow()
201  self._next_change_next_change = utc_point_in_time + timedelta(days=400)
202 
203  # Work our way around the solar cycle, figure out the next
204  # phase. Some of these are stored.
205  self.locationlocation.solar_depression = "astronomical"
206  self._check_event_check_event(utc_point_in_time, "dawn", PHASE_NIGHT)
207  self.locationlocation.solar_depression = "nautical"
208  self._check_event_check_event(utc_point_in_time, "dawn", PHASE_ASTRONOMICAL_TWILIGHT)
209  self.locationlocation.solar_depression = "civil"
210  self.next_dawnnext_dawn = self._check_event_check_event(
211  utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT
212  )
213  self.next_risingnext_rising = self._check_event_check_event(
214  utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT
215  )
216  self.locationlocation.solar_depression = -10
217  self._check_event_check_event(utc_point_in_time, "dawn", PHASE_SMALL_DAY)
218  self.next_noonnext_noon = self._check_event_check_event(utc_point_in_time, "noon", None)
219  self._check_event_check_event(utc_point_in_time, "dusk", PHASE_DAY)
220  self.next_settingnext_setting = self._check_event_check_event(
221  utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY
222  )
223  self.locationlocation.solar_depression = "civil"
224  self.next_dusknext_dusk = self._check_event_check_event(utc_point_in_time, "dusk", PHASE_TWILIGHT)
225  self.locationlocation.solar_depression = "nautical"
226  self._check_event_check_event(utc_point_in_time, "dusk", PHASE_NAUTICAL_TWILIGHT)
227  self.locationlocation.solar_depression = "astronomical"
228  self._check_event_check_event(utc_point_in_time, "dusk", PHASE_ASTRONOMICAL_TWILIGHT)
229  self.next_midnightnext_midnight = self._check_event_check_event(utc_point_in_time, "midnight", None)
230  self.locationlocation.solar_depression = "civil"
231 
232  # if the event was solar midday or midnight, phase will now
233  # be None. Solar noon doesn't always happen when the sun is
234  # even in the day at the poles, so we can't rely on it.
235  # Need to calculate phase if next is noon or midnight
236  if self.phasephase is None:
237  elevation = self.locationlocation.solar_elevation(self._next_change_next_change, self.elevationelevation)
238  if elevation >= 10:
239  self.phasephase = PHASE_DAY
240  elif elevation >= 0:
241  self.phasephase = PHASE_SMALL_DAY
242  elif elevation >= -6:
243  self.phasephase = PHASE_TWILIGHT
244  elif elevation >= -12:
245  self.phasephase = PHASE_NAUTICAL_TWILIGHT
246  elif elevation >= -18:
247  self.phasephase = PHASE_ASTRONOMICAL_TWILIGHT
248  else:
249  self.phasephase = PHASE_NIGHT
250 
251  self.risingrising = self.next_noonnext_noon < self.next_midnightnext_midnight
252 
253  _LOGGER.debug(
254  "sun phase_update@%s: phase=%s", utc_point_in_time.isoformat(), self.phasephase
255  )
256  if self._update_sun_position_listener_update_sun_position_listener:
257  self._update_sun_position_listener_update_sun_position_listener()
258  self.update_sun_positionupdate_sun_position()
259  async_dispatcher_send(self.hasshasshass, SIGNAL_EVENTS_CHANGED)
260 
261  # Set timer for the next solar event
262  self._update_events_listener_update_events_listener = event.async_track_point_in_utc_time(
263  self.hasshasshass, self.update_eventsupdate_events, self._next_change_next_change
264  )
265  _LOGGER.debug("next time: %s", self._next_change_next_change.isoformat())
266 
267  @callback
268  def update_sun_position(self, now: datetime | None = None) -> None:
269  """Calculate the position of the sun."""
270  # Grab current time in case system clock changed since last time we ran.
271  utc_point_in_time = dt_util.utcnow()
272  self.solar_azimuthsolar_azimuth = round(
273  self.locationlocation.solar_azimuth(utc_point_in_time, self.elevationelevation), 2
274  )
275  self.solar_elevationsolar_elevation = round(
276  self.locationlocation.solar_elevation(utc_point_in_time, self.elevationelevation), 2
277  )
278 
279  _LOGGER.debug(
280  "sun position_update@%s: elevation=%s azimuth=%s",
281  utc_point_in_time.isoformat(),
282  self.solar_elevationsolar_elevation,
283  self.solar_azimuthsolar_azimuth,
284  )
285  self.async_write_ha_stateasync_write_ha_state()
286 
287  async_dispatcher_send(self.hasshasshass, SIGNAL_POSITION_CHANGED)
288 
289  # Next update as per the current phase
290  assert self.phasephase
291  delta = _PHASE_UPDATES[self.phasephase]
292  # if the next update is within 1.25 of the next
293  # position update just drop it
294  if utc_point_in_time + delta * 1.25 > self._next_change_next_change:
295  self._update_sun_position_listener_update_sun_position_listener = None
296  return
297  self._update_sun_position_listener_update_sun_position_listener = event.async_track_point_in_utc_time(
298  self.hasshasshass, self.update_sun_positionupdate_sun_position, utc_point_in_time + delta
299  )
datetime _check_event(self, datetime utc_point_in_time, str sun_event, str|None before)
Definition: entity.py:187
dict[str, Any] extra_state_attributes(self)
Definition: entity.py:171
None update_location(self, Event|None _=None, bool initial=False)
Definition: entity.py:140
None __init__(self, HomeAssistant hass)
Definition: entity.py:120
None update_events(self, datetime|None now=None)
Definition: entity.py:197
None update_sun_position(self, datetime|None now=None)
Definition: entity.py:268
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
tuple[astral.location.Location, astral.Elevation] get_astral_location(HomeAssistant hass)
Definition: sun.py:32
datetime.datetime get_location_astral_event_next(astral.location.Location location, astral.Elevation elevation, str event, datetime.datetime|None utc_point_in_time=None, datetime.timedelta|None offset=None)
Definition: sun.py:75