diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 90718fd540e..dda692a8d80 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -6,10 +6,9 @@ from homeassistant.const import ( CONF_ELEVATION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import ( - async_track_point_in_utc_time, async_track_utc_time_change) +from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.sun import ( - get_astral_location, get_astral_event_next) + get_astral_location, get_location_astral_event_next) from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -23,6 +22,7 @@ STATE_BELOW_HORIZON = 'below_horizon' STATE_ATTR_AZIMUTH = 'azimuth' STATE_ATTR_ELEVATION = 'elevation' +STATE_ATTR_RISING = 'rising' STATE_ATTR_NEXT_DAWN = 'next_dawn' STATE_ATTR_NEXT_DUSK = 'next_dusk' STATE_ATTR_NEXT_MIDNIGHT = 'next_midnight' @@ -30,6 +30,39 @@ STATE_ATTR_NEXT_NOON = 'next_noon' STATE_ATTR_NEXT_RISING = 'next_rising' STATE_ATTR_NEXT_SETTING = 'next_setting' +# The algorithm used here is somewhat complicated. It aims to cut down +# the number of sensor updates over the day. It's documented best in +# the PR for the change, see the Discussion section of: +# https://github.com/home-assistant/home-assistant/pull/23832 + + +# As documented in wikipedia: https://en.wikipedia.org/wiki/Twilight +# sun is: +# < -18° of horizon - all stars visible +PHASE_NIGHT = 'night' +# 18°-12° - some stars not visible +PHASE_ASTRONOMICAL_TWILIGHT = 'astronomical_twilight' +# 12°-6° - horizon visible +PHASE_NAUTICAL_TWILIGHT = 'nautical_twilight' +# 6°-0° - objects visible +PHASE_TWILIGHT = 'twilight' +# 0°-10° above horizon, sun low on horizon +PHASE_SMALL_DAY = 'small_day' +# > 10° above horizon +PHASE_DAY = 'day' + +# 4 mins is one degree of arc change of the sun on its circle. +# During the night and the middle of the day we don't update +# that much since it's not important. +_PHASE_UPDATES = { + PHASE_NIGHT: timedelta(minutes=4*5), + PHASE_ASTRONOMICAL_TWILIGHT: timedelta(minutes=4*2), + PHASE_NAUTICAL_TWILIGHT: timedelta(minutes=4*2), + PHASE_TWILIGHT: timedelta(minutes=4), + PHASE_SMALL_DAY: timedelta(minutes=2), + PHASE_DAY: timedelta(minutes=4), +} + async def async_setup(hass, config): """Track the state of the sun.""" @@ -37,10 +70,7 @@ async def async_setup(hass, config): _LOGGER.warning( "Elevation is now configured in home assistant core. " "See https://home-assistant.io/docs/configuration/basic/") - - sun = Sun(hass, get_astral_location(hass)) - sun.point_in_time_listener(dt_util.utcnow()) - + Sun(hass, get_astral_location(hass)) return True @@ -57,8 +87,10 @@ class Sun(Entity): self.next_dawn = self.next_dusk = None self.next_midnight = self.next_noon = None self.solar_elevation = self.solar_azimuth = None + self.rising = self.phase = None - async_track_utc_time_change(hass, self.timer_update, second=30) + self._next_change = None + self.update_events(dt_util.utcnow()) @property def name(self): @@ -83,57 +115,110 @@ class Sun(Entity): STATE_ATTR_NEXT_NOON: self.next_noon.isoformat(), STATE_ATTR_NEXT_RISING: self.next_rising.isoformat(), STATE_ATTR_NEXT_SETTING: self.next_setting.isoformat(), - STATE_ATTR_ELEVATION: round(self.solar_elevation, 2), - STATE_ATTR_AZIMUTH: round(self.solar_azimuth, 2) + STATE_ATTR_ELEVATION: self.solar_elevation, + STATE_ATTR_AZIMUTH: self.solar_azimuth, + STATE_ATTR_RISING: self.rising, } - @property - def next_change(self): - """Datetime when the next change to the state is.""" - return min(self.next_dawn, self.next_dusk, self.next_midnight, - self.next_noon, self.next_rising, self.next_setting) + def _check_event(self, utc_point_in_time, event, before): + next_utc = get_location_astral_event_next( + self.location, event, utc_point_in_time) + if next_utc < self._next_change: + self._next_change = next_utc + self.phase = before + return next_utc @callback - def update_as_of(self, utc_point_in_time): + def update_events(self, utc_point_in_time): """Update the attributes containing solar events.""" - self.next_dawn = get_astral_event_next( - self.hass, 'dawn', utc_point_in_time) - self.next_dusk = get_astral_event_next( - self.hass, 'dusk', utc_point_in_time) - self.next_midnight = get_astral_event_next( - self.hass, 'solar_midnight', utc_point_in_time) - self.next_noon = get_astral_event_next( - self.hass, 'solar_noon', utc_point_in_time) - self.next_rising = get_astral_event_next( - self.hass, SUN_EVENT_SUNRISE, utc_point_in_time) - self.next_setting = get_astral_event_next( - self.hass, SUN_EVENT_SUNSET, utc_point_in_time) + self._next_change = utc_point_in_time + timedelta(days=400) + + # Work our way around the solar cycle, figure out the next + # phase. Some of these are stored. + self.location.solar_depression = 'astronomical' + self._check_event(utc_point_in_time, 'dawn', PHASE_NIGHT) + self.location.solar_depression = 'nautical' + self._check_event( + utc_point_in_time, 'dawn', PHASE_ASTRONOMICAL_TWILIGHT) + self.location.solar_depression = 'civil' + self.next_dawn = self._check_event( + utc_point_in_time, 'dawn', PHASE_NAUTICAL_TWILIGHT) + self.next_rising = self._check_event( + utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT) + self.location.solar_depression = -10 + self._check_event(utc_point_in_time, 'dawn', PHASE_SMALL_DAY) + self.next_noon = self._check_event( + utc_point_in_time, 'solar_noon', None) + self._check_event(utc_point_in_time, 'dusk', PHASE_DAY) + self.next_setting = self._check_event( + utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY) + self.location.solar_depression = 'civil' + self.next_dusk = self._check_event( + utc_point_in_time, 'dusk', PHASE_TWILIGHT) + self.location.solar_depression = 'nautical' + self._check_event( + utc_point_in_time, 'dusk', PHASE_NAUTICAL_TWILIGHT) + self.location.solar_depression = 'astronomical' + self._check_event( + utc_point_in_time, 'dusk', PHASE_ASTRONOMICAL_TWILIGHT) + self.next_midnight = self._check_event( + utc_point_in_time, 'solar_midnight', None) + + # if the event was solar midday or midnight, phase will now + # be None. Solar noon doesn't always happen when the sun is + # even in the day at the poles, so we can't rely on it. + # Need to calculate phase if next is noon or midnight + if self.phase is None: + elevation = self.location.solar_elevation(self._next_change) + if elevation >= 10: + self.phase = PHASE_DAY + elif elevation >= 0: + self.phase = PHASE_SMALL_DAY + elif elevation >= -6: + self.phase = PHASE_TWILIGHT + elif elevation >= -12: + self.phase = PHASE_NAUTICAL_TWILIGHT + elif elevation >= -18: + self.phase = PHASE_ASTRONOMICAL_TWILIGHT + else: + self.phase = PHASE_NIGHT + + self.rising = self.next_noon < self.next_midnight + + _LOGGER.debug( + "sun phase_update@%s: phase=%s", + utc_point_in_time.isoformat(), + self.phase, + ) + self.update_sun_position(utc_point_in_time) + + # Set timer for the next solar event + async_track_point_in_utc_time( + self.hass, self.update_events, + self._next_change) + _LOGGER.debug("next time: %s", self._next_change.isoformat()) @callback def update_sun_position(self, utc_point_in_time): """Calculate the position of the sun.""" - self.solar_azimuth = self.location.solar_azimuth(utc_point_in_time) - self.solar_elevation = self.location.solar_elevation(utc_point_in_time) + self.solar_azimuth = round( + self.location.solar_azimuth(utc_point_in_time), 2) + self.solar_elevation = round( + self.location.solar_elevation(utc_point_in_time), 2) - @callback - def point_in_time_listener(self, now): - """Run when the state of the sun has changed.""" - self.update_sun_position(now) - self.update_as_of(now) + _LOGGER.debug( + "sun position_update@%s: elevation=%s azimuth=%s", + utc_point_in_time.isoformat(), + self.solar_elevation, self.solar_azimuth + ) self.async_write_ha_state() - _LOGGER.debug("sun point_in_time_listener@%s: %s, %s", - now, self.state, self.state_attributes) - # Schedule next update at next_change+1 second so sun state has changed + # Next update as per the current phase + delta = _PHASE_UPDATES[self.phase] + # if the next update is within 1.25 of the next + # position update just drop it + if utc_point_in_time + delta*1.25 > self._next_change: + return async_track_point_in_utc_time( - self.hass, self.point_in_time_listener, - self.next_change + timedelta(seconds=1)) - _LOGGER.debug("next time: %s", self.next_change + timedelta(seconds=1)) - - @callback - def timer_update(self, time): - """Needed to update solar elevation and azimuth.""" - self.update_sun_position(time) - self.async_write_ha_state() - _LOGGER.debug("sun timer_update@%s: %s, %s", - time, self.state, self.state_attributes) + self.hass, self.update_sun_position, + utc_point_in_time + delta) diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 049359a7313..bcb84234b84 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -43,9 +43,18 @@ def get_astral_event_next( utc_point_in_time: Optional[datetime.datetime] = None, offset: Optional[datetime.timedelta] = None) -> datetime.datetime: """Calculate the next specified solar event.""" - from astral import AstralError - location = get_astral_location(hass) + return get_location_astral_event_next( + location, event, utc_point_in_time, offset) + + +@callback +def get_location_astral_event_next( + location: 'astral.Location', event: str, + utc_point_in_time: Optional[datetime.datetime] = None, + offset: Optional[datetime.timedelta] = None) -> datetime.datetime: + """Calculate the next specified solar event.""" + from astral import AstralError if offset is None: offset = datetime.timedelta() diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 2833efa62c4..374527e2c8a 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -1,153 +1,177 @@ """The tests for the Sun component.""" -# pylint: disable=protected-access -import unittest +from datetime import datetime, timedelta from unittest.mock import patch -from datetime import timedelta, datetime -from homeassistant.setup import setup_component +from pytest import mark + +import homeassistant.components.sun as sun import homeassistant.core as ha import homeassistant.util.dt as dt_util -import homeassistant.components.sun as sun - -from tests.common import get_test_home_assistant +from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.setup import async_setup_component -# pylint: disable=invalid-name -class TestSun(unittest.TestCase): - """Test the sun module.""" +async def test_setting_rising(hass): + """Test retrieving sun setting and rising.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=utc_now): + await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + await hass.async_block_till_done() + state = hass.states.get(sun.ENTITY_ID) - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + from astral import Astral - def test_setting_rising(self): - """Test retrieving sun setting and rising.""" - utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) - with patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=utc_now): - setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + astral = Astral() + utc_today = utc_now.date() - self.hass.block_till_done() - state = self.hass.states.get(sun.ENTITY_ID) + latitude = hass.config.latitude + longitude = hass.config.longitude - from astral import Astral + mod = -1 + while True: + next_dawn = (astral.dawn_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_dawn > utc_now: + break + mod += 1 - astral = Astral() - utc_today = utc_now.date() + mod = -1 + while True: + next_dusk = (astral.dusk_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_dusk > utc_now: + break + mod += 1 - latitude = self.hass.config.latitude - longitude = self.hass.config.longitude + mod = -1 + while True: + next_midnight = (astral.solar_midnight_utc( + utc_today + timedelta(days=mod), longitude)) + if next_midnight > utc_now: + break + mod += 1 - mod = -1 - while True: - next_dawn = (astral.dawn_utc( - utc_today + timedelta(days=mod), latitude, longitude)) - if next_dawn > utc_now: - break - mod += 1 + mod = -1 + while True: + next_noon = (astral.solar_noon_utc( + utc_today + timedelta(days=mod), longitude)) + if next_noon > utc_now: + break + mod += 1 - mod = -1 - while True: - next_dusk = (astral.dusk_utc( - utc_today + timedelta(days=mod), latitude, longitude)) - if next_dusk > utc_now: - break - mod += 1 + mod = -1 + while True: + next_rising = (astral.sunrise_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_rising > utc_now: + break + mod += 1 - mod = -1 - while True: - next_midnight = (astral.solar_midnight_utc( - utc_today + timedelta(days=mod), longitude)) - if next_midnight > utc_now: - break - mod += 1 + mod = -1 + while True: + next_setting = (astral.sunset_utc( + utc_today + timedelta(days=mod), latitude, longitude)) + if next_setting > utc_now: + break + mod += 1 - mod = -1 - while True: - next_noon = (astral.solar_noon_utc( - utc_today + timedelta(days=mod), longitude)) - if next_noon > utc_now: - break - mod += 1 + assert next_dawn == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_DAWN]) + assert next_dusk == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_DUSK]) + assert next_midnight == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_MIDNIGHT]) + assert next_noon == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_NOON]) + assert next_rising == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_RISING]) + assert next_setting == dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_SETTING]) - mod = -1 - while True: - next_rising = (astral.sunrise_utc( - utc_today + timedelta(days=mod), latitude, longitude)) - if next_rising > utc_now: - break - mod += 1 - mod = -1 - while True: - next_setting = (astral.sunset_utc( - utc_today + timedelta(days=mod), latitude, longitude)) - if next_setting > utc_now: - break - mod += 1 +async def test_state_change(hass): + """Test if the state changes at next setting/rising.""" + now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=now): + await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) - assert next_dawn == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_DAWN]) - assert next_dusk == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_DUSK]) - assert next_midnight == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_MIDNIGHT]) - assert next_noon == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_NOON]) - assert next_rising == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_RISING]) - assert next_setting == dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_SETTING]) + await hass.async_block_till_done() - def test_state_change(self): - """Test if the state changes at next setting/rising.""" - now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC) - with patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=now): - setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + test_time = dt_util.parse_datetime( + hass.states.get(sun.ENTITY_ID) + .attributes[sun.STATE_ATTR_NEXT_RISING]) + assert test_time is not None - self.hass.block_till_done() + assert sun.STATE_BELOW_HORIZON == \ + hass.states.get(sun.ENTITY_ID).state - test_time = dt_util.parse_datetime( - self.hass.states.get(sun.ENTITY_ID) - .attributes[sun.STATE_ATTR_NEXT_RISING]) - assert test_time is not None + hass.bus.async_fire( + ha.EVENT_TIME_CHANGED, + {ha.ATTR_NOW: test_time + timedelta(seconds=5)}) - assert sun.STATE_BELOW_HORIZON == \ - self.hass.states.get(sun.ENTITY_ID).state + await hass.async_block_till_done() - self.hass.bus.fire(ha.EVENT_TIME_CHANGED, - {ha.ATTR_NOW: test_time + timedelta(seconds=5)}) + assert sun.STATE_ABOVE_HORIZON == \ + hass.states.get(sun.ENTITY_ID).state - self.hass.block_till_done() - assert sun.STATE_ABOVE_HORIZON == \ - self.hass.states.get(sun.ENTITY_ID).state +async def test_norway_in_june(hass): + """Test location in Norway where the sun doesn't set in summer.""" + hass.config.latitude = 69.6 + hass.config.longitude = 18.8 - def test_norway_in_june(self): - """Test location in Norway where the sun doesn't set in summer.""" - self.hass.config.latitude = 69.6 - self.hass.config.longitude = 18.8 + june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) - june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=june): + assert await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) - with patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=june): - assert setup_component(self.hass, sun.DOMAIN, { - sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + state = hass.states.get(sun.ENTITY_ID) + assert state is not None - state = self.hass.states.get(sun.ENTITY_ID) - assert state is not None + assert dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_RISING]) == \ + datetime(2016, 7, 25, 23, 23, 39, tzinfo=dt_util.UTC) + assert dt_util.parse_datetime( + state.attributes[sun.STATE_ATTR_NEXT_SETTING]) == \ + datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC) - assert dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_RISING]) == \ - datetime(2016, 7, 25, 23, 23, 39, tzinfo=dt_util.UTC) - assert dt_util.parse_datetime( - state.attributes[sun.STATE_ATTR_NEXT_SETTING]) == \ - datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC) + +@mark.skip +async def test_state_change_count(hass): + """Count the number of state change events in a location.""" + # Skipped because it's a bit slow. Has been validated with + # multiple lattitudes and dates + hass.config.latitude = 10 + hass.config.longitude = 0 + + now = datetime(2016, 6, 1, tzinfo=dt_util.UTC) + + with patch( + 'homeassistant.helpers.condition.dt_util.utcnow', + return_value=now): + assert await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + + events = [] + @ha.callback + def state_change_listener(event): + if event.data.get('entity_id') == 'sun.sun': + events.append(event) + hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) + await hass.async_block_till_done() + + for _ in range(24*60*60): + now += timedelta(seconds=1) + hass.bus.async_fire( + ha.EVENT_TIME_CHANGED, + {ha.ATTR_NOW: now}) + await hass.async_block_till_done() + + assert len(events) < 721