From eb2a06aa9f1d2662df2a3de10888dce1fa705867 Mon Sep 17 00:00:00 2001 From: Bob Carroll Date: Tue, 16 Jan 2024 21:18:01 -0600 Subject: [PATCH] replace schedule rules with automated range calculations --- cherry/circadian/__init__.py | 364 +++++++++++++++++++++-------------- circadian.yaml | 80 +++----- requirements.txt | 1 - setup.py | 5 +- 4 files changed, 246 insertions(+), 204 deletions(-) diff --git a/cherry/circadian/__init__.py b/cherry/circadian/__init__.py index 6445426..887fd06 100644 --- a/cherry/circadian/__init__.py +++ b/cherry/circadian/__init__.py @@ -18,187 +18,258 @@ import sys import asyncio from contextlib import AsyncExitStack -from datetime import datetime, timedelta +from collections import namedtuple import logging import yaml from dns.asyncresolver import resolve from asyncio_mqtt import Client import umsgpack -from dateutil.parser import parse + +Rule = namedtuple('Rule', 'bri ct') -async def apply_rule(rule, client): +class SceneManager(object): """ - Sets the scene from a rule. - - :param rule: a rule dictionary - :param client: mqtt client - """ - name = rule.get('name') - logging.debug(f'Begin applying rule: {name}') - - for light, payload in rule.get('scene', {}).items(): - payload = {k: v - for k, v in payload.items() - if k in ['mode', 'brightness', 'effect']} - - if not len(payload): - logging.warn(f'Light {light} payload is malformed') - continue - - logging.debug(f'Setting state for light {light}: {payload}') - await client.publish(f'light/{light}/state/set', umsgpack.packb(payload)) - - logging.debug(f'End applying rule: {name}') - - -async def check_elevation(trigger, state): - """ - Determines if the current sun elevation matches the trigger. - - :param trigger: a trigger dictionary - :param state: shared state dictionary - :returns: True on match, False otherwise - """ - elevation = state.get('elevation') - is_rising = state.get('rising') - - at = trigger.get('at', 0) - until = trigger.get('until', 0) - on_rising = at <= until - - if elevation is None or is_rising is None: - return False - elif is_rising and (not on_rising or elevation < at or elevation >= until): - return False - elif not is_rising and (on_rising or elevation > at or elevation <= until): - return False - - return True - - -async def check_time(trigger): - """ - Determines if the current time matches the trigger. - - :param trigger: a trigger dictionary - :returns: True on match, False otherwise - """ - at_time = parse(trigger.get('at')) - before = at_time + timedelta(minutes=trigger.get('for', 1)) - return at_time <= datetime.now() < before - - -async def match_trigger(trigger, state): - """ - Looks for matching triggers. - - :param trigger: a trigger dictionary - :param state: shared state dictionary - :returns: True on match, False otherwise - """ - type_ = trigger.get('type') - - if type_ == 'elevation': - return await check_elevation(trigger, state) - elif type_ == 'time': - return await check_time(trigger) - else: - logging.warn(f'Unknown trigger type: {type_}') - - return False - - -async def periodic_rule_check(client, state, mutex): - """ - Periodically checks for a matching rule to apply. + Manages light state changes. :param client: mqtt client + :param rule: new light state values :param state: shared state dictionary - :param mutex: lock for making changes to shared state """ - async with mutex: - rules = state['rules'] - scene_name = state['scene-name'] - while True: - logging.debug('Begin rule check') - await mutex.acquire() - active = state.get('active', -1) + def __init__(self, client, rule, state): + self.client = client + self.rule = rule + self.elevation = state['solar']['elevation'] + self.direction = state['solar']['direction'] + self.auto_on = state['options'].get('auto_on', False) - for i, r in enumerate(rules): - if active > -1 and i != active: - logging.debug('Skipping, another rule is active') - continue + def get_auto_on_config(self, options): + """ + Gets the solar elevation and direction settings for a light. - try: - logging.debug(f'Checking rule: {r.get("name")}') - matched = await match_trigger(r.get('trigger', {}), state) + :param options: light configuration + :returns: a tuple of elevation and direction values + """ + return options.get('auto_on_elevation'), options.get('auto_on_direction', 1) - if matched and active == i: - logging.debug('Rule has already been triggered') - break - elif matched: - logging.info(f'Rule triggered: {r.get("name")}') - await apply_rule(r, client) - await client.publish('scene/activate', scene_name) - active = i - break - elif active == i: - logging.debug('Resetting, rule no longer matches') - active = -1 - except Exception as ex: - logging.error(str(ex)) - break + def check_auto_on(self, options): + """ + Determiens whether a light should automatically turn on based on configured and current + solar elevation and direction. - state['active'] = active - mutex.release() - logging.debug('End rule check') - await asyncio.sleep(30) + :param options: light configuration + :returns: True if the light should turn on, False otherwise + """ + elevation, direction = self.get_auto_on_config(options) + + if self.auto_on and elevation is not None and self.direction == direction: + tod = 'morning' if direction else 'evening' + logging.debug(f'Auto-on will trigger in the {tod} at {elevation} degrees') + + if direction and self.elevation >= elevation: + return True + elif not direction and self.elevation <= elevation: + return True + + return False + + def adjust_brightness(self, state): + """ + Adjusts the rule brightness value given an offset. + + :param state: light state dictionary + :returns: new brightness value + """ + offset = state['options'].get('brightness_offset', 0) + return max(min(self.rule.bri + offset, 100), 0) + + def adjust_colour_temp(self, state): + """ + Adjusts the rule colour temperature value given an offset. + + :param state: light state dictionary + :returns: new colour temperature value + """ + offset = state['options'].get('colour_temp_offset', 0) + return max(min(self.rule.ct + offset, 10000), 0) + + async def turn_on(self, name, state): + """ + Turns on a light based on current state and configuration. + + :param name: light name + :param state: light state dictionary + """ + bri = self.adjust_brightness(state) + ct = self.adjust_colour_temp(state) + payload = {'mode': 'dim', + 'brightness': bri, + 'effect': {'type': 'temperature', + 'value': f'{ct}K'}} + + if state.get('on', False) or self.check_auto_on(state['options']): + logging.debug(f'Setting state for light {name}: {payload}') + await self.client.publish(f'light/{name}/state/set', umsgpack.packb(payload)) + else: + logging.debug(f'Skipping light {name} because it is off') -async def on_sun_event(state, mutex, messages): +def match_step_rule(rules, current): """ - Event handler for sun state changes. + Gets the closest rule for the given index. + :param rules: dictionary of step rules + :param current: the current step index + :returns: the matched rule + """ + return rules[min(rules.keys(), key=lambda k: abs(k - current))] + + +async def update_light_states(client, state): + """ + Updates the state of all lights based on the solar state. + + :param client mqtt client + :param state: shared state dictionary + """ + if state['paused']: + logging.debug('Circadian automation is paused, skipping') + return + elif state['solar']['elevation'] < 0: + logging.debug('Sun is below the horizon, skipping') + return + + index = (state['solar']['elevation'] / state['solar']['noon']) * 100 + logging.debug(f'Current step index: {index}') + + rule = match_step_rule(state['rules'], index) + logging.debug(f'Matched step rule: {rule}') + scene = SceneManager(client, rule, state) + + for light_name, light_state in state['lights'].items(): + await scene.turn_on(light_name, light_state) + + await client.publish('scene/activate', state['scene_name']) + + +async def on_solar_state_change(client, state, mutex, messages): + """ + Event handler for receiving solar state changes. + + :param client: mqtt client :param state: shared state dictionary :param mutex: lock for making changes to shared state :param messages: mqtt message generator """ async for m in messages: try: - _, event = m.topic.split('/') - async with mutex: - if event == 'elevation': - state['elevation'] = round(float(m.payload), 1) - logging.debug(f'Received sun elevation: {state["elevation"]}') - elif event == 'rising': - state['rising'] = bool(int(m.payload)) - logging.debug(f'Received sun rising state: {state["rising"]}') + state['solar'] = umsgpack.unpackb(m.payload) + logging.debug(f'Received solar state: {state["solar"]}') + await update_light_states(client, state) except Exception as ex: logging.error(str(ex)) -async def on_reset(state, mutex, messages): +async def on_light_event(client, state, mutex, messages): """ - Handler for resetting the active scene. + Event handler for receiving light 'on' changes. + :param client: mqtt client :param state: shared state dictionary :param mutex: lock for making changes to shared state :param messages: mqtt message generator """ async for m in messages: try: - if int(m.payload) == 1: - logging.debug('Resetting active scene') - async with mutex: - state['active'] = -1 + async with mutex: + _, name, _ = m.topic.split('/', 3) + logging.debug(f'Received light {name} state: {m.payload}') + + if name in state['lights']: + current = state['lights'][name]['on'] + state['lights'][name]['on'] = bool(int(m.payload)) + + if not current and 'solar' in state: + await update_light_states(client, state) except Exception as ex: logging.error(str(ex)) +async def on_pause(client, state, mutex, messages): + """ + Handler for pausing and unpausing the automation. + + :param client: mqtt client + :param state: shared state dictionary + :param mutex: lock for making changes to shared state + :param messages: mqtt message generator + """ + async for m in messages: + try: + async with mutex: + logging.debug(f'Setting paused state: {m.payload}') + state['paused'] = bool(int(m.payload)) + await client.publish('circadian/paused', m.payload, retain=True) + except Exception as ex: + logging.error(str(ex)) + + +def compute_steps(range_min, range_max, count): + """ + Computes the intervals given a range and step. + + :param range_min: + :param range_max: + :param count: the number of steps to iterate over + :returns: a list of intervals + """ + step = (range_max - range_min) / count + result = [] + i = 0 + + while range_min + i * step <= range_max: + result.append(round(range_min + i * step)) + i += 1 + + return result + + +def make_rule_map(config): + """ + Builds a mapping of step rules to light state rules. + + :param config: configuration dictionary + :returns: a dictionary of rules + """ + steps = min(max(config.get('change_steps', 1), 1), 100) + elev = compute_steps(0, 100, steps) + bri = compute_steps(max(config.get('min_brightness', 0), 0), + min(config.get('max_brightness', 100), 100), + steps) + ct = compute_steps(max(config.get('min_colour_temp', 0), 0), + min(config.get('max_colour_temp', 10000), 10000), + steps) + + return {e: Rule(b, t) for e, b, t in zip(elev, bri, ct)} + + +def make_light_map(config): + """ + Builds a mapping of light options and state. + + :param config: configuration dictionary + :returns: a dictionary of light states + """ + def transform(in_): + options = {k: v for k, v in in_.items() if k != 'name'} + return {'options': options, 'on': False} + + return {x['name']: transform(x) for x in config} + + async def get_broker(config): """ Gets the mqtt broker address from an SRV record. @@ -223,8 +294,11 @@ async def init(config): :param config: configuration dictionary """ - state = {'rules': config.get('rules', []), - 'scene-name': config.get('scene-name', 'Circadian')} + state = {'lights': make_light_map(config.get('lights', [])), + 'rules': make_rule_map(config.get('options', {})), + 'scene_name': config.get('scene_name', 'Circadian'), + 'options': config.get('options', {}), + 'paused': False} mutex = asyncio.Lock() tasks = set() @@ -234,22 +308,20 @@ async def init(config): logging.info('Connected to mqtt broker') topics = { - 'circadian/reset': on_reset, - 'sun/elevation': on_sun_event, - 'sun/rising': on_sun_event} + 'circadian/paused/set': on_pause, + 'light/+/on': on_light_event, + 'sun/state': on_solar_state_change} for t, cb in topics.items(): manager = client.filtered_messages(t) messages = await stack.enter_async_context(manager) - task = asyncio.create_task(cb(state, mutex, messages)) + task = asyncio.create_task(cb(client, state, mutex, messages)) tasks.add(task) await client.subscribe('circadian/#') + await client.publish('circadian/paused', 0, retain=True) + await client.subscribe('light/#') await client.subscribe('sun/#') - - task = asyncio.create_task(periodic_rule_check(client, state, mutex)) - tasks.add(task) - await asyncio.gather(*tasks) diff --git a/circadian.yaml b/circadian.yaml index e16d8d9..a538110 100644 --- a/circadian.yaml +++ b/circadian.yaml @@ -7,58 +7,30 @@ log: # CRITICAL, ERROR, WARNING, INFO, DEBUG level: INFO -scene-name: Circadian +options: + # Automatically turn lights on at specific elevations + auto_on: true + # Number of changes to make between sunrise/sunset and noon + change_steps: 20 + # Maxiumum brightness percentage (0-100) + max_brightness: 100 + # Maxium colour temperature in kelvin (for Hue, 2000-6500) + max_colour_temp: 6000 + # Minimum brightness percentage (0-100) + min_brightness: 60 + # Minimum colour temperature in kelvin (for Hue, 2000-6500) + min_colour_temp: 3000 + # Name of the scene to annouce on elevation change + scene_name: Circadian -rules: - - name: Early Morning - trigger: - type: elevation - at: 0.0 - until: 6.0 - scene: - - topic: hue/light/5/set - data: - 'on': true - bri: 128 - ct_k: 4000 - - - name: Late Morning - trigger: - type: elevation - at: 18.0 - until: 30.0 - scene: - - topic: hue/light/5/bri - data: 255 - - - name: Lunch - trigger: - type: time - at: '12:00' - # minutes - for: 60 - scene: - - topic: hue/light/8/set - data: - 'on': true - bri: 224 - ct_k: 3800 - - - name: Late Afternoon - trigger: - type: elevation - at: 18.0 - until: 12.0 - scene: - - topic: hue/light/5/set - data: - 'on': true - bri: 255 - ct_k: 3600 - - topic: hue/group/6/set - data: - 'on': true - bri: 255 - ct_k: 3600 - - topic: hue/light/8/on - data: 0 +lights: + - name: Nightstand + - name: Floor Lamp + # Offset in degrees to make lights brighter or dimmer + adjust_brightness: 10 + # Offset in kelvin to make lights warmer or cooler + adjust_colour_temp: -500 + # Solar elevation for when this light should be turned on + auto_on_elevation: 30 + # 1 for rising, 0 for setting + auto_on_direction: 0 diff --git a/requirements.txt b/requirements.txt index 00594b4..6431595 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ asyncio-mqtt==0.8.0 dnspython==2.1.0 paho-mqtt==1.5.1 -python-dateutil==2.8.1 PyYAML==5.3.1 six==1.15.0 u-msgpack-python==2.7.1 diff --git a/setup.py b/setup.py index fa966c4..7d05207 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_namespace_packages setup(name='cherry-circadian', - version='0.4', + version='0.5', description='adapts lighting based on the sun movement', author='Bob Carroll', author_email='bob.carroll@alum.rit.edu', @@ -11,8 +11,7 @@ setup(name='cherry-circadian', 'pyyaml', 'dnspython', 'asyncio_mqtt', - 'u-msgpack-python', - 'python-dateutil'], + 'u-msgpack-python'], classifiers=[ 'Development Status :: 2 - Pre-Alpha', 'Environment :: Console',