Quiet the chatty sun.sun (#23832)

* Split up method to allow caching event

* Lower frequency of updates.

* Code review patches.

* Minor changes to test

* Skip end of period at fixed multiple of delta.
Improved documentation.
This commit is contained in:
Penny Wood 2019-05-15 15:02:29 +08:00 committed by GitHub
parent 18149dcb8c
commit 9da74dda43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 289 additions and 171 deletions

View File

@ -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)

View File

@ -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()

View File

@ -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