diff --git a/README.md b/README.md index 4fb7326..4daea77 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,22 @@ -# update_lights -## This code acts on a list of lights to primarily do two things: +# Periodic lights -1) Gradually change the brightness level and the color temperature of the lights from the start to end time ONLY if the lights are currently on -2) When a light in the list is turned on and its brightness is not equal to the current level it immediately changes the settings to match other lights (if watch_light_state is True) +## Introduction -What makes this code different than others (namely custom component Circadian Lighting or Flux component) is the implementation of change thresholds; -so if someone manually adjusts a light outside of the threshold range the code will skip that light. -The brightness/color temp is calculated by determining how far from the middle point of the start and end time the current time is -in other words at start time and end time there is 100% brightness, at the exact middle point there is 0% brightness (or whatever the configured max and min are) -All lights can be mixed e.g. you can have a list with RGB, color temp, and brightness only lights together -There are numerous options that can be configured to adjust the gradient of the change and constrain whether or not the code is active +Periodic lights (formally update lights) is an automatic light brightness and color temperature adjustment tool for AppDaemon. This code will act on a provided list of lights to keep them in sync with the current light parameters. What makes this app different from others (namely custom components Circadian Lighting, Adaptive Lighting or the built-in Flux component) is the implementation of brightness change thresholds; this allows for manual adjustment of a light outside of the threshold range that the app will then ignore until and unless the light is either manually adjusted to the current threshold range, or the light is toggled. The brightness/color temperature is calculated by determining how far from the middle point of the start and end time the current time is. All light types can be mixed (e.g. you can have a list with RGB, color temp, and brightness only lights together). There are numerous options that can be configured to suit your needs. -Options: +## Options: --- Key | Required | Description | Default | Unit ------------ | ------------- | ------------- | ------------- | ------------- entities | True | List of lights | None | List -run_every | False | Time interval in seconds to run the code, set to 0 to disable time-based updates | 60 | Seconds +run_every | False | Time interval in seconds to run the code, set to 0 to disable time-based updates | 180 | Seconds event_subscription | False | Home-assistant event to listen for, forces lights to update, can take transition and threshold variables | None | string start_time | False | Time in format 'HH:MM:SS' to start; also can be 'sunset - HH:MM:SS' | sunset | Time end_time | False | Time in format 'HH:MM:SS' to start; also can be 'sunrise - HH:MM:SS' | sunrise | Time start_index | False | With this option you can push the middle point left or right and increase or decrease the brightness change gradient and point of minimum brightness/temp, takes time the same was as start/end times | None | Time end_index | False | Same as start index but changes the end time rather than start both can be configured to have 2 minimum brightness points | None | Time -brightness_threshold | False | Residual threshold between calculated brightness and current brightness if residual > this no change | 255 or 100 | Bit or percent +brightness_threshold | False | Residual threshold between calculated brightness and current brightness if residual > threshold no change | 255 or 100 | Bit or percent brightness_unit | False | percent or bit | bit | None max_brightness_level | False | Max brightness level | 255 or 100 | Bit or percent min_brightness_level | False | Max brightness level | 3 or 1 | Bit or percent @@ -35,21 +28,52 @@ disable_condition | False | Override default condition check for disable_entity sleep_entity | False | List of entities that track whether a 'sleep mode' has been enabled this immediatly brings lights to the lowest brightness and color temp defined. Can take a comma separated condition rather than disable condition key below (e.g. input_boolean.sleep_mode,on) | None | List sleep_condition | False | Override default condition check for sleep_entity | on, True, or Home | Boolean or string in list form red_hour | False | Time in format 'HH:MM:SS' during the start and stop times that the RGB lights turn red if sleep conditions are met | None | Time +sleep_color | False | Color in string format (e.g. 'red') | red | String transition | False | Light transition time in seconds | 5 | Seconds companion_script | False | Script to execute before changing lights, useful to force Zwave lights to update state | None | sensor_log | False | Creates a sensor to track the dimming percentage, mostly for diagnostic purposes, format: sensor.my_sensor | None | +sensor_only | False | Only creates a sensor that tracks the brightness and color temperature, will not adjust lights. | False | Boolean watch_light_state | False | Whether or not to watch individual lights and adjust them when they are turned on | True | Boolean keep_lights_on | False | Forces the light to turn on, in other words ignores that it is off | False | Boolean start_lights_on | False | Turn on the lights at the start time | False | Boolean stop_lights_off | False | Turn off the lights at the stop time | False | Boolean - AppDaemon constraints can be used as well, see AppDaemon API Docs https://appdaemon.readthedocs.io/en/latest/APPGUIDE.html#callback-constraints +## Algorithm explanation + +This app works by calculating the time that occurs exactly in the middle of the start_time and end_time. The current time is then compared to the middle point to determine at what point in the day the current time is relative to the middle point. This is then fit to a sin function (running from 0 to pi in the x direction). This returns a percentage, if your defined light brightness is from 0-100 then the direct reading from the sin function will be used. In practice this is calculated by: + +![equation.png](equation.png) + +The color temperature is calculated the same way as the brightness. + +The values are updated 24 hours a day, therefore, setting the run_every variable to a very short time will not result in the lights updating faster. There are 256 brightness bit values, assuming the maximum is set to 100% brightness and the minimum is set to 0%. This means there are only 512 brightness steps that can be taken in 1 day, or in other words, about 1 every 3 minutes. Therefore, in terms of brightness, the app should be set to update about once every 3 minutes. A similar calculation can be made for color temperature, assuming the default range in mired. Using the default settings, the brightness of the lights would look like this over the course of a day: + +![default_settings.png](default_settings.png) + +Both brightness and color temperature are subject to the ranges provided by the user or the default values described above. + +## Understanding start/end index + +This is a fine tuning feature that allows for the lights to be dimmer or brighter close to the start or end than they otherwise would by extending the middle-point. Using these effectively creates two middle points during the start and end window. A start_index value effects the result after the middle point, an end_index effects the result before the middle-point. If the lights are watched closely one would observe the lights dim to the lowest point from the start->middlepoint1 then hold at the minimum between middlepoint1->middlepoint2; after middlepoint2 the lights will become brighter as usual. + +This should be tested by setting up a template light not connected to anything and observing the behavior to see if the desired result is achieved. + +This is an example of the behavior of the light brighness with sunset->sunrise start and end times and the index settings below: + +![index_demo.png](index_demo.png) + +``` + start_index: sunset + 02:00:00 + end_index: sunrise - 02:00:00 +``` +Note the step change in brightness at the start and end times as the code switches from one mid-point to another, and the two hour minimum point in the middle of the night. The brightness step change should be accounted for in your threshold settings if you want the lights that are on to smoothly transition at that point. In the case of this example the step change is about 15%, therefore a brightness threshold above 15% should be sufficient to account for this change. + ## Example apps.yaml: ``` -main_update_lights: +main_periodic_lights: module: update_lights class: update_lights run_every: 180 @@ -97,7 +121,7 @@ main_update_lights: - sensor.arbitrary_sensor,arbitrary_condition sensor_log: sensor.main_lights -exterior_update_lights: +exterior_periodic_lights: module: update_lights class: update_lights run_every: 180 @@ -111,13 +135,25 @@ exterior_update_lights: start_lights_on: True stop_lights_off: True ``` + ## Example script/automation for event subscription: + ``` script: - force_light_update: sequence: - - event: main_update_lights + - event: main_periodic_lights event_data: threshold: 255 transition: 0 ``` +## Example sensor only configuration: +``` +sensor_only_periodic_lights: + module: update_lights + class: update_lights + run_every: 30 + sensor_only: True + start_index: sunset + 02:00:00 + end_index: sunrise - 02:00:00 +``` diff --git a/apps/update_lights/update_lights.py b/apps/update_lights/update_lights.py index f2e689a..c1f3e3d 100644 --- a/apps/update_lights/update_lights.py +++ b/apps/update_lights/update_lights.py @@ -20,18 +20,29 @@ def initialize(self): self.transition = int(self.args.get('transition', 5)) self.start_time = str(self.args.get('start_time', 'sunset')) self.end_time = str(self.args.get('end_time', 'sunrise')) - self.red_hour = str(self.args.get('red_hour', 'None')) + self.red_hour = self.args.get('red_hour', None) self.start_index = str(self.args.get('start_index', self.start_time)) self.end_index = str(self.args.get('end_index', self.end_time)) self.color_temp_unit = str(self.args.get('color_temp_unit', 'kelvin')) self.color_temp_max = int(self.args.get('color_temp_max', 4000)) self.color_temp_min = int(self.args.get('color_temp_min', 2200)) - self.watch_light_state = bool(self.args.get('watch_light_state', True)) - self.keep_lights_on = bool(self.args.get('keep_lights_on', False)) - self.start_lights_on = bool(self.args.get('start_lights_on', False)) - self.stop_lights_off = bool(self.args.get('stop_lights_off', False)) + self.watch_light_state = self.args.get('watch_light_state', True) + self.keep_lights_on = self.args.get('keep_lights_on', False) + self.start_lights_on = self.args.get('start_lights_on', False) + self.stop_lights_off = self.args.get('stop_lights_off', False) + self.sensor_only = self.args.get('sensor_only', False) self.event = self.args.get('event_subscription', None) + interval = int(self.args.get('run_every', 180)) + target = now + timedelta(seconds=interval) + + if self.sensor_only and self.sensor_only != 'false': + #Sensor only is specified + self.run_every(self.time_change, target, interval) + return + else: + self.sensor_only = False + if isinstance(self.all_lights, str): self.all_lights = self.all_lights.split(',') if isinstance(self.disable_entity, str): @@ -59,26 +70,26 @@ def initialize(self): if not isinstance(self.min_brightness_level, int) or self.min_brightness_level > 255 or self.min_brightness_level > self.max_brightness_level: self.min_brightness_level = 3 - if self.keep_lights_on or str(self.keep_lights_on).lower() == 'true': - self.keep_lights_on = True - else: + if str(self.keep_lights_on).lower() == 'false': self.keep_lights_on = False + else: + self.keep_lights_on = True - if self.start_lights_on or str(self.start_lights_on).lower() == 'true': + if str(self.start_lights_on).lower() == 'false': + self.start_lights_on = False + else: self.start_lights_on = True self.run_daily(self.lights_on, self.parse_time(self.start_time)) - else: - self.start_lights_on = False - if self.stop_lights_off or str(self.stop_lights_off).lower() == 'true': + if str(self.stop_lights_off).lower() == 'false': + self.stop_lights_off = False + else: self.stop_lights_off = True self.run_daily(self.lights_off, self.parse_time(self.end_time)) - else: - self.stop_lights_off = False + #Set callbacks for time interval, and subscribe to individual lights and disable/sleep entities - interval = int(self.args.get('run_every', 60)) - target = now + timedelta(seconds=interval) + if self.all_lights is not None: if self.disable_entity is not None: for entity in self.disable_entity: @@ -92,7 +103,7 @@ def initialize(self): self.listen_state(self.state_change, entity) if self.watch_light_state: for light in self.all_lights: - self.listen_state(self.state_change, light) + self.listen_state(self.state_change, light, oneshot = True) if interval > 0: self.run_every(self.time_change, target, interval) if self.event is not None: @@ -119,8 +130,10 @@ def event_subscription(self, event, data, kwargs): def state_change(self, entity, attribute, old, new, kwargs): threshold = 255 transition = 0 - if entity in self.all_lights and new == "on": - self.adjust_light(entity, threshold, transition) + if entity in self.all_lights: + if new == "on": + self.adjust_light(entity, threshold, transition) + self.run_in(self.resubscribe, 2, entity = entity) return if self.disable_entity is not None: for check_entity in self.disable_entity: @@ -133,6 +146,9 @@ def state_change(self, entity, attribute, old, new, kwargs): self.adjust_light(self.all_lights, threshold, transition) return + def resubscribe (self, kwargs): + self.listen_state(self.state_change, kwargs['entity'], oneshot = True) + def lights_on(self, kwargs): #Turn on all lights check = self.condition_query(self.disable_entity, self.disable_condition) @@ -154,45 +170,51 @@ def pct(self): dt = datetime.datetime.now() now_time = dt.timestamp() - start_ts = datetime.datetime.combine(self.date(), self.parse_time(self.start_time)) - end_ts = datetime.datetime.combine(self.date(), self.parse_time(self.end_time)) + start = datetime.datetime.combine(self.date(), self.parse_time(self.start_time)) + end = datetime.datetime.combine(self.date(), self.parse_time(self.end_time)) midnight = '0:00:00' - if self.now_is_between(self.start_time, self.end_time): - #We are in between the start and end times - if self.now_is_between(midnight, self.end_time) and int(start_ts.timestamp()) > int(end_ts.timestamp()): - #We are past midnight and the start time was the day before - start_ts = start_ts + timedelta(days=-1) - elif int(start_ts.timestamp()) > int(end_ts.timestamp()): - #We are before midnight and the end time is after midnight - end_ts = end_ts + timedelta(days=1) - - start_i_ts = datetime.datetime.combine(start_ts.date(), self.parse_time(self.start_index)) - end_i_ts = datetime.datetime.combine(end_ts.date(), self.parse_time(self.end_index)) - - start_ts = int(start_ts.timestamp()) - end_ts = int(end_ts.timestamp()) - start_i_ts = int(start_i_ts.timestamp()) - end_i_ts = int(end_i_ts.timestamp()) + if self.now_is_between(midnight, self.end_time) and not self.now_is_between(self.start_time, midnight): + #We are past midnight and the start time was the day before + self.log('Time delta start -1 day') + start = start + timedelta(days=-1) + elif self.now_is_between(self.end_time, midnight) and start > end: + #We are before midnight and the end time is after midnight + self.log('Time delta end +1 day') + end = end + timedelta(days=1) + #Get index times + start_i = datetime.datetime.combine(start.date(), self.parse_time(self.start_index)) + end_i = datetime.datetime.combine(end.date(), self.parse_time(self.end_index)) + if start_i > end_i: + #End is before midnight but end index is after + end_i = end_i + timedelta(days=1) + #Figure out midpoint + half_seconds = (end - start).total_seconds() / 2 + half = start + timedelta(seconds=half_seconds) + #Figure out start index midpoint + half_seconds = (end - start_i).total_seconds() / 2 + midpoint_start = start_i + timedelta(seconds=half_seconds) + #Figure out end index midpoint + half_seconds = (end_i - start).total_seconds() / 2 + midpoint_end = start + timedelta(seconds=half_seconds) + #Calculate the midpoint between start and end time incorpertaing the indexed times - midpoint_start = (start_i_ts + end_ts) / 2 - midpoint_end = (start_ts + end_i_ts) / 2 - if now_time < midpoint_start: - midpoint = midpoint_start - else: - midpoint = midpoint_end - - if now_time < start_ts and now_time > end_ts: - #We are outside of the start and end time so 0 dimming + if (dt > midpoint_start and dt < midpoint_end) or (dt < midpoint_start and dt > midpoint_end): + self.log('In the middle of the midpoints') pct = 0 + # elif self.now_is_between(self.end_time, self.start_time): + # midpoint = half.timestamp() else: - if now_time < midpoint: - #We are after start time but before midpoint (ramp down) - pct = float((now_time - start_ts) / (midpoint - start_ts)) + if dt < midpoint_start: + # midpoint = midpoint_start.timestamp() + midpoint = midpoint_end.timestamp() else: - #We are after midpoint but before end time (ramp up) - pct = 1 - float((now_time - midpoint) / (end_ts - midpoint)) - return pct + midpoint = midpoint_start.timestamp() + # midpoint = midpoint_end.timestamp() + + pct = abs(float(math.sin(math.pi*((now_time - midpoint) / (86400))))) + + return pct, half, midpoint_start, midpoint_end def color(self, pct): color_max = self.color_temp_max @@ -211,7 +233,7 @@ def color(self, pct): if sleep_state == False: #Calculate desired color temp - desired_temp_kelvin = round(int(color_max) - (abs(int(color_max) - int(color_min))* float(pct))) + desired_temp_kelvin = round(int(color_min) + (abs(int(color_max) - int(color_min))* float(pct))) else: desired_temp_kelvin = color_min desired_temp_mired = self.color_temperature_kelvin_to_mired(desired_temp_kelvin) @@ -260,27 +282,27 @@ def rgb_color(self, desired_temp): elif tmp_blue > 255: tmp_blue = 255 return tmp_red, tmp_green, tmp_blue - + def brightness(self, pct): max_brightness_level = self.max_brightness_level min_brightness_level = self.min_brightness_level brightness_unit = self.brightness_unit #Calculate brightness level in the defined range - brightness_level = int(max_brightness_level) - round(int(max_brightness_level - min_brightness_level) * pct) + brightness_level = int(min_brightness_level) + round(int(max_brightness_level - min_brightness_level) * pct) sleep_state = self.condition_query(self.sleep_entity, self.sleep_condition) if int(brightness_level) > int(max_brightness_level) and sleep_state != True: #If we are above 255 correct for that - brightness_level = int(max_brightness_level) + return int(max_brightness_level) elif int(brightness_level) < int(min_brightness_level) or sleep_state == True: #If we are below min or are in sleep state return int(min_brightness_level) return brightness_level def red_hour_query (self): - if self.red_hour is not 'None': + if self.red_hour is not None: try: if self.now_is_between(self.red_hour, self.end_time): return True @@ -315,25 +337,32 @@ def color_temperature_kelvin_to_mired(self, kelvin_temperature: float) -> float: return math.floor(1000000 / kelvin_temperature) def adjust_light(self, entities, threshold, transition): + #Calculate our percentage and midpoints + pct, half, midpoint_start, midpoint_end = self.pct() + #Calculate brightness and temp based on percentage + brightness_level = self.brightness(pct) + desired_temp_kelvin, desired_temp_mired = self.color(pct) + tmp_red, tmp_green, tmp_blue = self.rgb_color(desired_temp_kelvin) + #Output sensor log + if 'sensor_log' in self.args: + sensor_log = self.args['sensor_log'] + # self.set_state(self.args['sensor_log'], state=(pct*100), attributes = {"unit_of_measurement":"%", "note":"Percentage of dimming, inverted to brightness percent"}) + else: + sensor_log = 'sensor.' + self.name + self.set_state(sensor_log, state=(brightness_level/2.55), attributes = {"unit_of_measurement":"%", "note":"Light brightness", "Kelvin temperature": desired_temp_kelvin, "Mired temperature": desired_temp_mired, "RGB": [tmp_red, tmp_green, tmp_blue], "Midpoint": half, "Start index midpoint": midpoint_start, "End index midpoint": midpoint_end}) + #Check if any disable entities are blocking override = self.condition_query(self.disable_entity, self.disable_condition) - if override: + if override or self.sensor_only: return None + #Run companion script if defined if 'companion_script' in self.args: self.turn_on(entity_id=self.args['companion_script']) - - dt = datetime.datetime.now() - - pct = self.pct() - - if 'sensor_log' in self.args: - self.set_state(self.args['sensor_log'], state=(pct*100), attributes = {"unit_of_measurement":"%", "note":"Percentage of dimming, inverted to brightness percent"}) - - brightness_level = self.brightness(pct) - desired_temp_kelvin, desired_temp_mired = self.color(pct) + #Check if sleep conditions are met sleep_state = self.condition_query(self.sleep_entity, self.sleep_condition) + #Check if red hour conditions are met red_hour = self.red_hour_query() ########################## @@ -346,15 +375,16 @@ def adjust_light(self, entities, threshold, transition): kelvin_list = [] rgb_list = [] brightness_only_list = [] - + #Create service data structures for each light type rgb_service_data = {"brightness": brightness_level, "transition": transition} color_temp_service_data = {"brightness": brightness_level, "transition": transition} kelvin_service_data = {"brightness": brightness_level, "transition": transition} brightness_only_service_data = {"brightness": brightness_level, "transition": transition} for entity_id in entities: + #Loop through lights, checking the condition for each one. Append each compliant light to a list depending on what type of adjustment the light is capable of. cur_state = self.get_state(entity_id) - if (cur_state == 'on' or self.keep_lights_on): + if (cur_state == 'on' or (self.keep_lights_on and self.now_is_between(self.start_time, self.end_time))): brightness = self.get_state(entity_id, attribute="brightness") if (brightness is not None and (abs(int(brightness) - int(brightness_level)) < int(threshold)) and int(brightness) != int(brightness_level)) or self.keep_lights_on or (red_hour and sleep_state): color_temp = self.get_state(entity_id, attribute='color_temp') @@ -374,10 +404,9 @@ def adjust_light(self, entities, threshold, transition): tmp_red = 255 tmp_green = 0 tmp_blue = 0 - rgb_service_data['brightness'] = int((self.max_brightness_level + self.min_brightness_level) / 2) + # rgb_service_data['brightness'] = int((self.max_brightness_level + self.min_brightness_level) / 2) rgb_service_data['color_name'] = self.sleep_color else: - tmp_red, tmp_green, tmp_blue = self.rgb_color(desired_temp_kelvin) rgb_service_data['rgb_color'] = [int(tmp_red), int(tmp_green), int(tmp_blue)] rgb_service_data['entity_id'] = rgb_list self.call_service("light/turn_on", **rgb_service_data) diff --git a/index_demo.png b/index_demo.png new file mode 100644 index 0000000..d1da050 Binary files /dev/null and b/index_demo.png differ