Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 69 additions & 66 deletions custom_components/circadian_lighting/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
lights to 2700K (warm white) until your hub goes into Night mode
"""

import bisect
import logging
from datetime import timedelta

Expand Down Expand Up @@ -72,52 +73,66 @@
CONF_SUNRISE_TIME = "sunrise_time"
CONF_SUNSET_TIME = "sunset_time"
DEFAULT_TRANSITION = 60
CONF_PROFILE, DEFAULT_PROFILE = "profile", "default"

CONFIG_SCHEMA = vol.Schema(
_DOMAIN_SCHEMA = vol.Schema(
{
DOMAIN: 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_SUNRISE_OFFSET): cv.time_period_str,
vol.Optional(CONF_SUNSET_OFFSET): cv.time_period_str,
vol.Optional(CONF_SUNRISE_TIME): cv.time,
vol.Optional(CONF_SUNSET_TIME): cv.time,
vol.Optional(CONF_LATITUDE): cv.latitude,
vol.Optional(CONF_LONGITUDE): cv.longitude,
vol.Optional(CONF_ELEVATION): float,
vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): cv.time_period,
vol.Optional(
ATTR_TRANSITION, default=DEFAULT_TRANSITION
): VALID_TRANSITION,
}
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,
vol.Optional(CONF_SUNSET_TIME): cv.time,
vol.Optional(CONF_LATITUDE): cv.latitude,
vol.Optional(CONF_LONGITUDE): cv.longitude,
vol.Optional(CONF_ELEVATION): float,
vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): cv.time_period,
vol.Optional(ATTR_TRANSITION, default=DEFAULT_TRANSITION): VALID_TRANSITION,
vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): cv.string,
}
)


def _all_unique_profiles(value):
"""Validate that all enties have a unique profile name."""
hosts = [device[CONF_PROFILE] for device in value]
schema = vol.Schema(vol.Unique())
schema(hosts)
return value


CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [_DOMAIN_SCHEMA], _all_unique_profiles)},
extra=vol.ALLOW_EXTRA,
)


def setup(hass, config):
"""Set up the Circadian Lighting platform."""
conf = config[DOMAIN]
hass.data[DOMAIN] = CircadianLighting(
hass,
min_colortemp=conf.get(CONF_MIN_CT),
max_colortemp=conf.get(CONF_MAX_CT),
sunrise_offset=conf.get(CONF_SUNRISE_OFFSET),
sunset_offset=conf.get(CONF_SUNSET_OFFSET),
sunrise_time=conf.get(CONF_SUNRISE_TIME),
sunset_time=conf.get(CONF_SUNSET_TIME),
latitude=conf.get(CONF_LATITUDE, hass.config.latitude),
longitude=conf.get(CONF_LONGITUDE, hass.config.longitude),
elevation=conf.get(CONF_ELEVATION, hass.config.elevation),
interval=conf.get(CONF_INTERVAL),
transition=conf.get(ATTR_TRANSITION),
)
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
configs = config[DOMAIN]
for conf in configs:
profile = conf[CONF_PROFILE]
hass.data[DOMAIN][profile] = CircadianLighting(
hass,
min_colortemp=conf[CONF_MIN_CT],
max_colortemp=conf[CONF_MAX_CT],
sunrise_offset=conf.get(CONF_SUNRISE_OFFSET),
sunset_offset=conf.get(CONF_SUNSET_OFFSET),
sunrise_time=conf.get(CONF_SUNRISE_TIME),
sunset_time=conf.get(CONF_SUNSET_TIME),
latitude=conf.get(CONF_LATITUDE, hass.config.latitude),
longitude=conf.get(CONF_LONGITUDE, hass.config.longitude),
elevation=conf.get(CONF_ELEVATION, hass.config.elevation),
interval=conf[CONF_INTERVAL],
transition=conf[ATTR_TRANSITION],
profile=profile,
)
load_platform(hass, "sensor", DOMAIN, {}, config)

return True
Expand All @@ -140,6 +155,7 @@ def __init__(
elevation,
interval,
transition,
profile,
):
self.hass = hass
self._min_colortemp = min_colortemp
Expand All @@ -152,8 +168,10 @@ def __init__(
self._longitude = longitude
self._elevation = elevation
self._transition = transition
self._profile = profile
_LOGGER.debug("profile: %s", self._profile)

self._percent = self.calc_percent()
self._percent = self._calc_percent()
self._colortemp = self.calc_colortemp()
self._rgb_color = self.calc_rgb()
self._xy_color = self.calc_xy()
Expand Down Expand Up @@ -192,7 +210,7 @@ def _replace_time(self, date, key):
microsecond=other_date.microsecond,
)

def get_sunrise_sunset(self, date):
def _get_sun_events(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")
Expand Down Expand Up @@ -235,34 +253,19 @@ def get_sunrise_sunset(self, date):
k: dt.astimezone(dt_util.UTC).timestamp() for k, dt in datetimes.items()
}

def calc_percent(self):
def _relevant_events(self, now):
events = []
for days in [-1, 0, 1]:
sun_events = self._get_sun_events(now + timedelta(days=days))
events.extend(list(sun_events.items()))
events = sorted(events, key=lambda x: x[1])
index_now = bisect.bisect([ts for _, ts in events], now.timestamp())
return dict(events[index_now - 2 : index_now + 2])

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]

today = self._relevant_events(now)
# 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.
Expand Down Expand Up @@ -315,7 +318,7 @@ def calc_hs(self):

async def update(self, _=None):
"""Update Circadian Values."""
self._percent = self.calc_percent()
self._percent = self._calc_percent()
self._colortemp = self.calc_colortemp()
self._rgb_color = self.calc_rgb()
self._xy_color = self.calc_xy()
Expand Down
26 changes: 11 additions & 15 deletions custom_components/circadian_lighting/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,19 @@
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity

from . import CIRCADIAN_LIGHTING_UPDATE_TOPIC, DOMAIN
from . import CIRCADIAN_LIGHTING_UPDATE_TOPIC, DEFAULT_PROFILE, DOMAIN

ICON = "mdi:theme-light-dark"


def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Circadian Lighting sensor."""
circadian_lighting = hass.data.get(DOMAIN)
if circadian_lighting is not None:
sensor = CircadianSensor(hass, circadian_lighting)
add_devices([sensor], True)

def update(call=None):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the service_call because I am not sure how to best do it once there are multiple sensors. Also I think it's not really needed. If you want frequent updates, just decease the interval?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how much the service call is used, TBH. Off the top of my head I could imagine it would be useful with the only_once option where a user may want to control exactly when a light adjusts (e.g. any time there's motion).

"""Update component."""
circadian_lighting.update()

service_name = "values_update"
hass.services.register(DOMAIN, service_name, update)
return True
else:
return False
sensors = [
CircadianSensor(hass, circadian_lighting)
for circadian_lighting in hass.data[DOMAIN].values()
]
add_devices(sensors, True)
return True


class CircadianSensor(Entity):
Expand All @@ -37,6 +29,10 @@ def __init__(self, hass, circadian_lighting):
self._circadian_lighting = circadian_lighting
self._name = "Circadian Values"
self._entity_id = "sensor.circadian_values"
profile = circadian_lighting._profile
if profile != DEFAULT_PROFILE:
self._name += f" {profile}"
self._entity_id += f"_{profile.lower()}"
self._unit_of_measurement = "%"
self._icon = ICON

Expand Down
53 changes: 25 additions & 28 deletions custom_components/circadian_lighting/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
color_xy_to_hs,
)

from . import CIRCADIAN_LIGHTING_UPDATE_TOPIC, DOMAIN
from . import CIRCADIAN_LIGHTING_UPDATE_TOPIC, DOMAIN, CONF_PROFILE, DEFAULT_PROFILE

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -89,39 +89,36 @@
CONF_INITIAL_TRANSITION, default=DEFAULT_INITIAL_TRANSITION
): VALID_TRANSITION,
vol.Optional(CONF_ONLY_ONCE, default=False): cv.boolean,
vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): cv.string,
}
)


def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Circadian Lighting switches."""
circadian_lighting = hass.data.get(DOMAIN)
if circadian_lighting is not None:
switch = CircadianSwitch(
hass,
circadian_lighting,
name=config.get(CONF_NAME),
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.get(CONF_DISABLE_BRIGHTNESS_ADJUST),
min_brightness=config.get(CONF_MIN_BRIGHT),
max_brightness=config.get(CONF_MAX_BRIGHT),
sleep_entity=config.get(CONF_SLEEP_ENTITY),
sleep_state=config.get(CONF_SLEEP_STATE),
sleep_colortemp=config.get(CONF_SLEEP_CT),
sleep_brightness=config.get(CONF_SLEEP_BRIGHT),
disable_entity=config.get(CONF_DISABLE_ENTITY),
disable_state=config.get(CONF_DISABLE_STATE),
initial_transition=config.get(CONF_INITIAL_TRANSITION),
only_once=config.get(CONF_ONLY_ONCE),
)
add_devices([switch])

return True
else:
return False
profile = config[CONF_PROFILE]
circadian_lighting = hass.data[DOMAIN][profile]
switch = CircadianSwitch(
hass,
circadian_lighting,
name=config[CONF_NAME],
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),
initial_transition=config[CONF_INITIAL_TRANSITION],
only_once=config[CONF_ONLY_ONCE],
)
add_devices([switch])


def _difference_between_states(from_state, to_state):
Expand Down