From 4aff61d8cb1b007cba5b054ecda92b4e04bd9bd9 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 11 Sep 2020 20:31:51 +0200 Subject: [PATCH] move to single switch --- .../circadian_lighting/__init__.py | 73 +-- .../circadian_lighting/sensor.py | 11 +- .../circadian_lighting/switch.py | 419 ++++++++++++++---- 3 files changed, 339 insertions(+), 164 deletions(-) diff --git a/custom_components/circadian_lighting/__init__.py b/custom_components/circadian_lighting/__init__.py index 903bb8e6..1a0967c7 100755 --- a/custom_components/circadian_lighting/__init__.py +++ b/custom_components/circadian_lighting/__init__.py @@ -76,12 +76,8 @@ CONF_PROFILE, DEFAULT_PROFILE = "profile", "default" _DOMAIN_SCHEMA = vol.Schema( { - vol.Optional(CONF_MIN_CT, default=DEFAULT_MIN_CT): vol.All( - vol.Coerce(int), vol.Range(min=1000, max=10000) - ), - vol.Optional(CONF_MAX_CT, default=DEFAULT_MAX_CT): vol.All( - vol.Coerce(int), vol.Range(min=1000, max=10000) - ), + vol.Optional(CONF_MIN_CT, default=DEFAULT_MIN_CT): vol.All(vol.Coerce(int), vol.Range(min=1000, max=10000)), + vol.Optional(CONF_MAX_CT, default=DEFAULT_MAX_CT): vol.All(vol.Coerce(int), vol.Range(min=1000, max=10000)), vol.Optional(CONF_SUNRISE_OFFSET): cv.time_period_str, vol.Optional(CONF_SUNSET_OFFSET): cv.time_period_str, vol.Optional(CONF_SUNRISE_TIME): cv.time, @@ -104,10 +100,7 @@ def _all_unique_profiles(value): return value -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [_DOMAIN_SCHEMA], _all_unique_profiles)}, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.All(cv.ensure_list, [_DOMAIN_SCHEMA], _all_unique_profiles)}, extra=vol.ALLOW_EXTRA,) def setup(hass, config): @@ -141,20 +134,7 @@ class CircadianLighting: """Calculate universal Circadian values.""" def __init__( - self, - hass, - min_colortemp, - max_colortemp, - sunrise_offset, - sunset_offset, - sunrise_time, - sunset_time, - latitude, - longitude, - elevation, - interval, - transition, - profile, + self, hass, min_colortemp, max_colortemp, sunrise_offset, sunset_offset, sunrise_time, sunset_time, latitude, longitude, elevation, interval, transition, profile, ): self.hass = hass self._min_colortemp = min_colortemp @@ -178,22 +158,14 @@ class CircadianLighting: if self._manual_sunrise is not None: async_track_time_change( - self.hass, - self.update, - hour=self._manual_sunrise.hour, - minute=self._manual_sunrise.minute, - second=self._manual_sunrise.second, + self.hass, self.update, hour=self._manual_sunrise.hour, minute=self._manual_sunrise.minute, second=self._manual_sunrise.second, ) else: async_track_sunrise(self.hass, self.update, self._sunrise_offset) if self._manual_sunset is not None: async_track_time_change( - self.hass, - self.update, - hour=self._manual_sunset.hour, - minute=self._manual_sunset.minute, - second=self._manual_sunset.second, + self.hass, self.update, hour=self._manual_sunset.hour, minute=self._manual_sunset.minute, second=self._manual_sunset.second, ) else: async_track_sunset(self.hass, self.update, self._sunset_offset) @@ -202,12 +174,7 @@ class CircadianLighting: def _replace_time(self, date, key): other_date = self._manual_sunrise if key == "sunrise" else self._manual_sunset - return date.replace( - hour=other_date.hour, - minute=other_date.minute, - second=other_date.second, - microsecond=other_date.microsecond, - ) + return date.replace(hour=other_date.hour, minute=other_date.minute, second=other_date.second, microsecond=other_date.microsecond,) def get_sunrise_sunset(self, date): if self._manual_sunrise is not None and self._manual_sunset is not None: @@ -248,9 +215,7 @@ class CircadianLighting: SUN_EVENT_MIDNIGHT: solar_midnight, } - return { - k: dt.astimezone(dt_util.UTC).timestamp() for k, dt in datetimes.items() - } + return {k: dt.astimezone(dt_util.UTC).timestamp() for k, dt in datetimes.items()} def calc_percent(self): now = dt_util.utcnow() @@ -261,10 +226,7 @@ class CircadianLighting: # It's before sunrise (after midnight), because it's before # sunrise (and after midnight) sunset must have happend yesterday. yesterday = self.get_sunrise_sunset(now - timedelta(days=1)) - if ( - today[SUN_EVENT_MIDNIGHT] > today[SUN_EVENT_SUNSET] - and yesterday[SUN_EVENT_MIDNIGHT] > yesterday[SUN_EVENT_SUNSET] - ): + if today[SUN_EVENT_MIDNIGHT] > today[SUN_EVENT_SUNSET] and yesterday[SUN_EVENT_MIDNIGHT] > yesterday[SUN_EVENT_SUNSET]: # Solar midnight is after sunset so use yesterdays's time today[SUN_EVENT_MIDNIGHT] = yesterday[SUN_EVENT_MIDNIGHT] today[SUN_EVENT_SUNSET] = yesterday[SUN_EVENT_SUNSET] @@ -272,10 +234,7 @@ class CircadianLighting: # It's after sunset (before midnight), because it's after sunset # (and before midnight) sunrise should happen tomorrow. tomorrow = self.get_sunrise_sunset(now + timedelta(days=1)) - if ( - today[SUN_EVENT_MIDNIGHT] < today[SUN_EVENT_SUNRISE] - and tomorrow[SUN_EVENT_MIDNIGHT] < tomorrow[SUN_EVENT_SUNRISE] - ): + if today[SUN_EVENT_MIDNIGHT] < today[SUN_EVENT_SUNRISE] and tomorrow[SUN_EVENT_MIDNIGHT] < tomorrow[SUN_EVENT_SUNRISE]: # Solar midnight is before sunrise so use tomorrow's time today[SUN_EVENT_MIDNIGHT] = tomorrow[SUN_EVENT_MIDNIGHT] today[SUN_EVENT_SUNRISE] = tomorrow[SUN_EVENT_SUNRISE] @@ -291,22 +250,14 @@ class CircadianLighting: h = today[SUN_EVENT_NOON] k = 100 # parabola before solar_noon else after solar_noon - x = ( - today[SUN_EVENT_SUNRISE] - if now_ts < today[SUN_EVENT_NOON] - else today[SUN_EVENT_SUNSET] - ) + x = today[SUN_EVENT_SUNRISE] if now_ts < today[SUN_EVENT_NOON] else today[SUN_EVENT_SUNSET] # sunset -> sunrise parabola elif today[SUN_EVENT_SUNSET] < now_ts < today[SUN_EVENT_SUNRISE]: h = today[SUN_EVENT_MIDNIGHT] k = -100 # parabola before solar_midnight else after solar_midnight - x = ( - today[SUN_EVENT_SUNSET] - if now_ts < today[SUN_EVENT_MIDNIGHT] - else today[SUN_EVENT_SUNRISE] - ) + x = today[SUN_EVENT_SUNSET] if now_ts < today[SUN_EVENT_MIDNIGHT] else today[SUN_EVENT_SUNRISE] y = 0 a = (y - k) / (h - x) ** 2 diff --git a/custom_components/circadian_lighting/sensor.py b/custom_components/circadian_lighting/sensor.py index 4461fa6a..5b5e2855 100755 --- a/custom_components/circadian_lighting/sensor.py +++ b/custom_components/circadian_lighting/sensor.py @@ -13,10 +13,7 @@ ICON = "mdi:theme-light-dark" def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Circadian Lighting sensor.""" - sensors = [ - CircadianSensor(hass, circadian_lighting) - for circadian_lighting in hass.data[DOMAIN].values() - ] + sensors = [CircadianSensor(hass, circadian_lighting) for circadian_lighting in hass.data[DOMAIN].values()] add_devices(sensors, True) return True @@ -81,11 +78,7 @@ class CircadianSensor(Entity): async def async_added_to_hass(self) -> None: """Connect dispatcher to signal from CircadianLighting object.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, CIRCADIAN_LIGHTING_UPDATE_TOPIC, self._update_callback - ) - ) + self.async_on_remove(async_dispatcher_connect(self.hass, CIRCADIAN_LIGHTING_UPDATE_TOPIC, self._update_callback)) @callback def _update_callback(self) -> None: diff --git a/custom_components/circadian_lighting/switch.py b/custom_components/circadian_lighting/switch.py index 62366ad4..07327757 100755 --- a/custom_components/circadian_lighting/switch.py +++ b/custom_components/circadian_lighting/switch.py @@ -1,14 +1,42 @@ """ -Circadian Lighting Switch for Home-Assistant. +Circadian Lighting Component for Home-Assistant. + +This component calculates color temperature and brightness to synchronize +your color changing lights with perceived color temperature of the sky throughout +the day. This gives your environment a more natural feel, with cooler whites during +the midday and warmer tints near twilight and dawn. + +In addition, the component sets your lights to a nice warm white at 1% in "Sleep" mode, +which is far brighter than starlight but won't reset your circadian rhythm or break down +too much rhodopsin in your eyes. + +Human circadian rhythms are heavily influenced by ambient light levels and +hues. Hormone production, brainwave activity, mood and wakefulness are +just some of the cognitive functions tied to cyclical natural light. +http://en.wikipedia.org/wiki/Zeitgeber + +Here's some further reading: + +http://www.cambridgeincolour.com/tutorials/sunrise-sunset-calculator.htm +http://en.wikipedia.org/wiki/Color_temperature + +Technical notes: I had to make a lot of assumptions when writing this app + * There are no considerations for weather or altitude, but does use your + hub's location to calculate the sun position. + * The component doesn't calculate a true "Blue Hour" -- it just sets the + lights to 2700K (warm white) until your hub goes into Night mode """ import asyncio import logging +from datetime import timedelta from itertools import repeat +import astral import voluptuous as vol import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -22,13 +50,23 @@ from homeassistant.components.light import VALID_TRANSITION, is_on from homeassistant.components.switch import SwitchEntity from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, CONF_NAME, CONF_PLATFORM, SERVICE_TURN_ON, STATE_ON, + SUN_EVENT_SUNRISE, + SUN_EVENT_SUNSET, +) +from homeassistant.helpers.event import ( + async_track_state_change, + async_track_sunrise, + async_track_sunset, + async_track_time_change, + async_track_time_interval, ) -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import slugify from homeassistant.util.color import ( @@ -38,85 +76,118 @@ from homeassistant.util.color import ( color_xy_to_hs, ) -from . import CIRCADIAN_LIGHTING_UPDATE_TOPIC, DOMAIN, CONF_PROFILE, DEFAULT_PROFILE - _LOGGER = logging.getLogger(__name__) ICON = "mdi:theme-light-dark" +DOMAIN = "circadian_lighting" +SUN_EVENT_NOON = "solar_noon" +SUN_EVENT_MIDNIGHT = "solar_midnight" + +CONF_LIGHTS_BRIGHT = "lights_brightness" CONF_LIGHTS_CT = "lights_ct" CONF_LIGHTS_RGB = "lights_rgb" CONF_LIGHTS_XY = "lights_xy" -CONF_LIGHTS_BRIGHT = "lights_brightness" + CONF_DISABLE_BRIGHTNESS_ADJUST = "disable_brightness_adjust" -CONF_MIN_BRIGHT, DEFAULT_MIN_BRIGHT = "min_brightness", 1 -CONF_MAX_BRIGHT, DEFAULT_MAX_BRIGHT = "max_brightness", 100 -CONF_SLEEP_ENTITY = "sleep_entity" -CONF_SLEEP_STATE = "sleep_state" -CONF_SLEEP_CT, DEFAULT_SLEEP_CT = "sleep_colortemp", 1000 -CONF_SLEEP_BRIGHT, DEFAULT_SLEEP_BRIGHT = "sleep_brightness", 1 CONF_DISABLE_ENTITY = "disable_entity" CONF_DISABLE_STATE = "disable_state" CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 +CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 300 +CONF_MAX_BRIGHT, DEFAULT_MAX_BRIGHT = "max_brightness", 100 +CONF_MAX_CT, DEFAULT_MAX_CT = "max_colortemp", 5500 +CONF_MIN_BRIGHT, DEFAULT_MIN_BRIGHT = "min_brightness", 1 +CONF_MIN_CT, DEFAULT_MIN_CT = "min_colortemp", 2500 CONF_ONLY_ONCE = "only_once" +CONF_SLEEP_BRIGHT, DEFAULT_SLEEP_BRIGHT = "sleep_brightness", 1 +CONF_SLEEP_CT, DEFAULT_SLEEP_CT = "sleep_colortemp", 1000 +CONF_SLEEP_ENTITY = "sleep_entity" +CONF_SLEEP_STATE = "sleep_state" +CONF_SUNRISE_OFFSET = "sunrise_offset" +CONF_SUNRISE_TIME = "sunrise_time" +CONF_SUNSET_OFFSET = "sunset_offset" +CONF_SUNSET_TIME = "sunset_time" +DEFAULT_TRANSITION = 60 PLATFORM_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): "circadian_lighting", vol.Optional(CONF_NAME, default="Circadian Lighting"): cv.string, + vol.Optional(CONF_LIGHTS_BRIGHT): cv.entity_ids, vol.Optional(CONF_LIGHTS_CT): cv.entity_ids, vol.Optional(CONF_LIGHTS_RGB): cv.entity_ids, vol.Optional(CONF_LIGHTS_XY): cv.entity_ids, - vol.Optional(CONF_LIGHTS_BRIGHT): cv.entity_ids, vol.Optional(CONF_DISABLE_BRIGHTNESS_ADJUST, default=False): cv.boolean, - vol.Optional(CONF_MIN_BRIGHT, default=DEFAULT_MIN_BRIGHT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=100) - ), - vol.Optional(CONF_MAX_BRIGHT, default=DEFAULT_MAX_BRIGHT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=100) - ), - vol.Optional(CONF_SLEEP_ENTITY): cv.entity_id, - vol.Optional(CONF_SLEEP_STATE): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_SLEEP_CT, default=DEFAULT_SLEEP_CT): vol.All( - vol.Coerce(int), vol.Range(min=1000, max=10000) - ), - vol.Optional(CONF_SLEEP_BRIGHT, default=DEFAULT_SLEEP_BRIGHT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=100) - ), vol.Optional(CONF_DISABLE_ENTITY): cv.entity_id, vol.Optional(CONF_DISABLE_STATE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ELEVATION): float, vol.Optional( CONF_INITIAL_TRANSITION, default=DEFAULT_INITIAL_TRANSITION ): VALID_TRANSITION, + vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): cv.time_period, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MAX_BRIGHT, default=DEFAULT_MAX_BRIGHT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=100) + ), + vol.Optional(CONF_MAX_CT, default=DEFAULT_MAX_CT): vol.All( + vol.Coerce(int), vol.Range(min=1000, max=10000) + ), + vol.Optional(CONF_MIN_BRIGHT, default=DEFAULT_MIN_BRIGHT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=100) + ), + vol.Optional(CONF_MIN_CT, default=DEFAULT_MIN_CT): vol.All( + vol.Coerce(int), vol.Range(min=1000, max=10000) + ), vol.Optional(CONF_ONLY_ONCE, default=False): cv.boolean, - vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): cv.string, + vol.Optional(CONF_SLEEP_BRIGHT, default=DEFAULT_SLEEP_BRIGHT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=100) + ), + vol.Optional(CONF_SLEEP_CT, default=DEFAULT_SLEEP_CT): vol.All( + vol.Coerce(int), vol.Range(min=1000, max=10000) + ), + vol.Optional(CONF_SLEEP_ENTITY): cv.entity_id, + vol.Optional(CONF_SLEEP_STATE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_SUNRISE_OFFSET): cv.time_period_str, + vol.Optional(CONF_SUNRISE_TIME): cv.time, + vol.Optional(CONF_SUNSET_OFFSET): cv.time_period_str, + vol.Optional(CONF_SUNSET_TIME): cv.time, + vol.Optional(ATTR_TRANSITION, default=DEFAULT_TRANSITION): VALID_TRANSITION, } ) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Circadian Lighting switches.""" - profile = config[CONF_PROFILE] - circadian_lighting = hass.data[DOMAIN][profile] switch = CircadianSwitch( hass, - circadian_lighting, name=config[CONF_NAME], + lights_brightness=config.get(CONF_LIGHTS_BRIGHT, []), lights_ct=config.get(CONF_LIGHTS_CT, []), lights_rgb=config.get(CONF_LIGHTS_RGB, []), lights_xy=config.get(CONF_LIGHTS_XY, []), - lights_brightness=config.get(CONF_LIGHTS_BRIGHT, []), disable_brightness_adjust=config[CONF_DISABLE_BRIGHTNESS_ADJUST], - min_brightness=config[CONF_MIN_BRIGHT], - max_brightness=config[CONF_MAX_BRIGHT], - sleep_entity=config.get(CONF_SLEEP_ENTITY), - sleep_state=config.get(CONF_SLEEP_STATE), - sleep_colortemp=config[CONF_SLEEP_CT], - sleep_brightness=config[CONF_SLEEP_BRIGHT], disable_entity=config.get(CONF_DISABLE_ENTITY), disable_state=config.get(CONF_DISABLE_STATE), + elevation=config.get(CONF_ELEVATION, hass.config.elevation), initial_transition=config[CONF_INITIAL_TRANSITION], + interval=config[CONF_INTERVAL], + latitude=config.get(CONF_LATITUDE, hass.config.latitude), + longitude=config.get(CONF_LONGITUDE, hass.config.longitude), + max_brightness=config[CONF_MAX_BRIGHT], + max_colortemp=config[CONF_MAX_CT], + min_brightness=config[CONF_MIN_BRIGHT], + min_colortemp=config[CONF_MIN_CT], only_once=config[CONF_ONLY_ONCE], + sleep_brightness=config[CONF_SLEEP_BRIGHT], + sleep_colortemp=config[CONF_SLEEP_CT], + sleep_entity=config.get(CONF_SLEEP_ENTITY), + sleep_state=config.get(CONF_SLEEP_STATE), + sunrise_offset=config.get(CONF_SUNRISE_OFFSET), + sunrise_time=config.get(CONF_SUNRISE_TIME), + sunset_offset=config.get(CONF_SUNSET_OFFSET), + sunset_time=config.get(CONF_SUNSET_TIME), + transition=config[ATTR_TRANSITION], ) add_devices([switch]) @@ -160,50 +231,77 @@ class CircadianSwitch(SwitchEntity, RestoreEntity): def __init__( self, hass, - circadian_lighting, name, + lights_brightness, lights_ct, lights_rgb, lights_xy, - lights_brightness, disable_brightness_adjust, - min_brightness, - max_brightness, - sleep_entity, - sleep_state, - sleep_colortemp, - sleep_brightness, disable_entity, disable_state, + elevation, initial_transition, + interval, + latitude, + longitude, + max_brightness, + max_colortemp, + min_brightness, + min_colortemp, only_once, + sleep_brightness, + sleep_colortemp, + sleep_entity, + sleep_state, + sunrise_offset, + sunrise_time, + sunset_offset, + sunset_time, + transition, ): """Initialize the Circadian Lighting switch.""" self.hass = hass - self._circadian_lighting = circadian_lighting self._name = name self._entity_id = f"switch.circadian_lighting_{slugify(name)}" self._state = None self._icon = ICON - self._hs_color = None - self._brightness = None - self._disable_brightness_adjust = disable_brightness_adjust - self._min_brightness = min_brightness - self._max_brightness = max_brightness - self._sleep_entity = sleep_entity - self._sleep_state = sleep_state - self._sleep_colortemp = sleep_colortemp - self._sleep_brightness = sleep_brightness - self._disable_entity = disable_entity - self._disable_state = disable_state - self._initial_transition = initial_transition - self._only_once = only_once self._lights_types = dict(zip(lights_ct, repeat("ct"))) + self._lights_types.update(zip(lights_brightness, repeat("brightness"))) self._lights_types.update(zip(lights_rgb, repeat("rgb"))) self._lights_types.update(zip(lights_xy, repeat("xy"))) - self._lights_types.update(zip(lights_brightness, repeat("brightness"))) self._lights = list(self._lights_types.keys()) + self._disable_brightness_adjust = disable_brightness_adjust + self._disable_entity = disable_entity + self._disable_state = disable_state + self._elevation = elevation + self._initial_transition = initial_transition + self._interval = interval + self._latitude = latitude + self._longitude = longitude + self._max_brightness = max_brightness + self._max_colortemp = max_colortemp + self._min_brightness = min_brightness + self._min_colortemp = min_colortemp + self._only_once = only_once + self._sleep_brightness = sleep_brightness + self._sleep_colortemp = sleep_colortemp + self._sleep_entity = sleep_entity + self._sleep_state = sleep_state + self._sunrise_offset = sunrise_offset + self._sunrise_time = sunrise_time + self._sunset_offset = sunset_offset + self._sunset_time = sunset_time + self._transition = transition + + self._percent = None + self._brightness = None + self._colortemp_kelvin = None + self._colortemp_mired = None + self._rgb_color = None + self._xy_color = None + self._hs_color = None + @property def entity_id(self): """Return the entity ID of the switch.""" @@ -221,13 +319,6 @@ class CircadianSwitch(SwitchEntity, RestoreEntity): async def async_added_to_hass(self): """Call when entity about to be added to hass.""" - # Add callback - self.async_on_remove( - async_dispatcher_connect( - self.hass, CIRCADIAN_LIGHTING_UPDATE_TOPIC, self._update_switch - ) - ) - # Add listeners async_track_state_change( self.hass, self._lights, self._light_state_changed, to_state="on" @@ -245,6 +336,32 @@ class CircadianSwitch(SwitchEntity, RestoreEntity): from_state=self._disable_state, ) + # XXX FROM __INIT__ \/ + if self._manual_sunrise is not None: + async_track_time_change( + self.hass, + self.update, + hour=self._manual_sunrise.hour, + minute=self._manual_sunrise.minute, + second=self._manual_sunrise.second, + ) + else: + async_track_sunrise(self.hass, self.update, self._sunrise_offset) + + if self._manual_sunset is not None: + async_track_time_change( + self.hass, + self.update, + hour=self._manual_sunset.hour, + minute=self._manual_sunset.minute, + second=self._manual_sunset.second, + ) + else: + async_track_sunset(self.hass, self.update, self._sunset_offset) + + async_track_time_interval(self.hass, self.update, self._interval) + # XXX FROM __INIT__ ^ + if self._state is not None: # If not None, we got an initial value return @@ -276,47 +393,161 @@ class CircadianSwitch(SwitchEntity, RestoreEntity): self._hs_color = None self._brightness = None + def _replace_time(self, date, key): + other_date = self._manual_sunrise if key == "sunrise" else self._manual_sunset + return date.replace( + hour=other_date.hour, + minute=other_date.minute, + second=other_date.second, + microsecond=other_date.microsecond, + ) + + def get_sunrise_sunset(self, date): + if self._manual_sunrise is not None and self._manual_sunset is not None: + sunrise = self._replace_time(date, "sunrise") + sunset = self._replace_time(date, "sunset") + solar_noon = sunrise + (sunset - sunrise) / 2 + solar_midnight = sunset + ((sunrise + timedelta(days=1)) - sunset) / 2 + else: + location = astral.Location() + location.name = "name" + location.region = "region" + location.latitude = self._latitude + location.longitude = self._longitude + location.elevation = self._elevation + + if self._manual_sunrise is not None: + sunrise = self._replace_time(date, "sunrise") + else: + sunrise = location.sunrise(date) + + if self._manual_sunset is not None: + sunset = self._replace_time(date, "sunset") + else: + sunset = location.sunset(date) + + solar_noon = location.solar_noon(date) + solar_midnight = location.solar_midnight(date) + + if self._sunrise_offset is not None: + sunrise = sunrise + self._sunrise_offset + if self._sunset_offset is not None: + sunset = sunset + self._sunset_offset + + datetimes = { + SUN_EVENT_SUNRISE: sunrise, + SUN_EVENT_SUNSET: sunset, + SUN_EVENT_NOON: solar_noon, + SUN_EVENT_MIDNIGHT: solar_midnight, + } + + return { + k: dt.astimezone(dt_util.UTC).timestamp() for k, dt in datetimes.items() + } + + def _calc_percent(self): + now = dt_util.utcnow() + now_ts = now.timestamp() + + today = self.get_sunrise_sunset(now) + if now_ts < today[SUN_EVENT_SUNRISE]: + # It's before sunrise (after midnight), because it's before + # sunrise (and after midnight) sunset must have happend yesterday. + yesterday = self.get_sunrise_sunset(now - timedelta(days=1)) + if ( + today[SUN_EVENT_MIDNIGHT] > today[SUN_EVENT_SUNSET] + and yesterday[SUN_EVENT_MIDNIGHT] > yesterday[SUN_EVENT_SUNSET] + ): + # Solar midnight is after sunset so use yesterdays's time + today[SUN_EVENT_MIDNIGHT] = yesterday[SUN_EVENT_MIDNIGHT] + today[SUN_EVENT_SUNSET] = yesterday[SUN_EVENT_SUNSET] + elif now_ts > today[SUN_EVENT_SUNSET]: + # It's after sunset (before midnight), because it's after sunset + # (and before midnight) sunrise should happen tomorrow. + tomorrow = self.get_sunrise_sunset(now + timedelta(days=1)) + if ( + today[SUN_EVENT_MIDNIGHT] < today[SUN_EVENT_SUNRISE] + and tomorrow[SUN_EVENT_MIDNIGHT] < tomorrow[SUN_EVENT_SUNRISE] + ): + # Solar midnight is before sunrise so use tomorrow's time + today[SUN_EVENT_MIDNIGHT] = tomorrow[SUN_EVENT_MIDNIGHT] + today[SUN_EVENT_SUNRISE] = tomorrow[SUN_EVENT_SUNRISE] + + # Figure out where we are in time so we know which half of the + # parabola to calculate. We're generating a different + # sunset-sunrise parabola for before and after solar midnight. + # because it might not be half way between sunrise and sunset. + # We're also generating a different parabola for sunrise-sunset. + + # sunrise -> sunset parabola + if today[SUN_EVENT_SUNRISE] < now_ts < today[SUN_EVENT_SUNSET]: + h = today[SUN_EVENT_NOON] + k = 100 + # parabola before solar_noon else after solar_noon + x = ( + today[SUN_EVENT_SUNRISE] + if now_ts < today[SUN_EVENT_NOON] + else today[SUN_EVENT_SUNSET] + ) + + # sunset -> sunrise parabola + elif today[SUN_EVENT_SUNSET] < now_ts < today[SUN_EVENT_SUNRISE]: + h = today[SUN_EVENT_MIDNIGHT] + k = -100 + # parabola before solar_midnight else after solar_midnight + x = ( + today[SUN_EVENT_SUNSET] + if now_ts < today[SUN_EVENT_MIDNIGHT] + else today[SUN_EVENT_SUNRISE] + ) + + y = 0 + a = (y - k) / (h - x) ** 2 + percentage = a * (now_ts - h) ** 2 + k + return percentage + + async def update(self, _=None): # from __init__ + """Update Circadian Values.""" + self._percent = self._calc_percent() + self._brightness = self._calc_brightness() + self._colortemp_kelvin = self._calc_colortemp_kelvin() + self._colortemp_mired = color_temperature_kelvin_to_mired( + self._colortemp_kelvin + ) + self._rgb_color = color_temperature_to_rgb(self._colortemp_kelvin) + self._xy_color = color_RGB_to_xy(*self._rgb_color) + self._hs_color = color_xy_to_hs(*self._xy_color) + def _is_sleep(self): return ( self._sleep_entity is not None and self.hass.states.get(self._sleep_entity).state in self._sleep_state ) - def _color_temperature(self): - return ( - self._circadian_lighting._colortemp - if not self._is_sleep() - else self._sleep_colortemp - ) - - def _calc_ct(self): - return color_temperature_kelvin_to_mired(self._color_temperature()) - - def _calc_rgb(self): - return color_temperature_to_rgb(self._color_temperature()) - - def _calc_xy(self): - return color_RGB_to_xy(*self._calc_rgb()) - - def _calc_hs(self): - return color_xy_to_hs(*self._calc_xy()) + def _calc_colortemp_kelvin(self): + if self._is_sleep(): + return self._sleep_colortemp + if self._percent > 0: + delta = self._max_colortemp - self._min_colortemp + percent = self._percent / 100 + return (delta * percent) + self._min_colortemp + return self._min_colortemp def _calc_brightness(self) -> float: if self._disable_brightness_adjust: return if self._is_sleep(): return self._sleep_brightness - if self._circadian_lighting._percent > 0: + if self._percent > 0: return self._max_brightness delta_brightness = self._max_brightness - self._min_brightness - percent = (100 + self._circadian_lighting._percent) / 100 + percent = (100 + self._percent) / 100 return (delta_brightness * percent) + self._min_brightness async def _update_switch(self, lights=None, transition=None, force=False): if self._only_once and not force: return - self._hs_color = self._calc_hs() - self._brightness = self._calc_brightness() + await self.update() await self._adjust_lights(lights or self._lights, transition) async def _force_update_switch(self, lights=None): @@ -342,7 +573,7 @@ class CircadianSwitch(SwitchEntity, RestoreEntity): return if transition is None: - transition = self._circadian_lighting._transition + transition = self._transition tasks = [] for light in lights: @@ -355,12 +586,12 @@ class CircadianSwitch(SwitchEntity, RestoreEntity): light_type = self._lights_types[light] if light_type == "ct": - service_data[ATTR_COLOR_TEMP] = int(self._calc_ct()) + service_data[ATTR_COLOR_TEMP] = int(self._colortemp_mired) elif light_type == "rgb": - r, g, b = self._calc_rgb() + r, g, b = self._rgb_color service_data[ATTR_RGB_COLOR] = (int(r), int(g), int(b)) elif light_type == "xy": - service_data[ATTR_XY_COLOR] = self._calc_xy() + service_data[ATTR_XY_COLOR] = self._xy_color if service_data.get(ATTR_BRIGHTNESS, False): service_data[ATTR_WHITE_VALUE] = service_data[ATTR_BRIGHTNESS]