diff --git a/.env-services-dist b/.env-services-dist new file mode 100644 index 0000000..e54313d --- /dev/null +++ b/.env-services-dist @@ -0,0 +1,5 @@ +# homeassistant_db +MYSQL_DATABASE= +MYSQL_USER= +MYSQL_ROOT_PASSWORD= +MYSQL_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da50aba --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/etc/config/.cloud +/etc/config/.storage +/etc/config/deps +/etc/config/tts +/etc/config/.HA_VERSION +/etc/config/home-assistant.log +/etc/config/secrets.yaml +/etc/homeassistant_db +/.env-homeassistant_db diff --git a/README.md b/README.md index 6621964..b2de4e0 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,6 @@ ## services - [docker hub](https://hub.docker.com/r/homeassistant/home-assistant) - [docs](https://www.home-assistant.io/) + +## example +- https://github.com/arsaboo/homeassistant-config diff --git a/docker-compose.yml b/docker-compose.yml index f17c71c..4b5162d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,34 @@ version: "3" services: - home-assistant: + homeassistant_instance: image: homeassistant/home-assistant:2021.2.3 - container_name: home-assistant - hostname: home-assistant + container_name: homeassistant_instance + hostname: homeassistant_instance restart: always + depends_on: + - homeassistant_db logging: driver: json-file options: max-size: "10m" max-file: "5" - volumes: - /etc/localtime:/etc/localtime:ro - ./etc/config:/config + ports: - "8123:8123" + homeassistant_db: + image: mariadb:10.5 + container_name: homeassistant_db + + hostname: homeassistant_db + restart: unless-stopped + + env_file: + - ./.env-homeassistant_db + + volumes: + - ./etc/homeassistant_db/data:/var/lib/mysql diff --git a/etc/config/automations.yaml b/etc/config/automations.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/etc/config/automations.yaml @@ -0,0 +1 @@ +[] diff --git a/etc/config/blueprints/automation/homeassistant/motion_light.yaml b/etc/config/blueprints/automation/homeassistant/motion_light.yaml new file mode 100644 index 0000000..c11d22d --- /dev/null +++ b/etc/config/blueprints/automation/homeassistant/motion_light.yaml @@ -0,0 +1,50 @@ +blueprint: + name: Motion-activated Light + description: Turn on a light when motion is detected. + domain: automation + source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml + input: + motion_entity: + name: Motion Sensor + selector: + entity: + domain: binary_sensor + device_class: motion + light_target: + name: Light + selector: + target: + entity: + domain: light + no_motion_wait: + name: Wait time + description: Time to leave the light on after last motion is detected. + default: 120 + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds + +# If motion is detected within the delay, +# we restart the script. +mode: restart +max_exceeded: silent + +trigger: + platform: state + entity_id: !input motion_entity + from: "off" + to: "on" + +action: + - service: light.turn_on + target: !input light_target + - wait_for_trigger: + platform: state + entity_id: !input motion_entity + from: "on" + to: "off" + - delay: !input no_motion_wait + - service: light.turn_off + target: !input light_target diff --git a/etc/config/blueprints/automation/homeassistant/notify_leaving_zone.yaml b/etc/config/blueprints/automation/homeassistant/notify_leaving_zone.yaml new file mode 100644 index 0000000..d3a70d7 --- /dev/null +++ b/etc/config/blueprints/automation/homeassistant/notify_leaving_zone.yaml @@ -0,0 +1,43 @@ +blueprint: + name: Zone Notification + description: Send a notification to a device when a person leaves a specific zone. + domain: automation + source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml + input: + person_entity: + name: Person + selector: + entity: + domain: person + zone_entity: + name: Zone + selector: + entity: + domain: zone + notify_device: + name: Device to notify + description: Device needs to run the official Home Assistant app to receive notifications. + selector: + device: + integration: mobile_app + +trigger: + platform: state + entity_id: !input person_entity + +variables: + zone_entity: !input zone_entity + # This is the state of the person when it's in this zone. + zone_state: "{{ states[zone_entity].name }}" + person_entity: !input person_entity + person_name: "{{ states[person_entity].name }}" + +condition: + condition: template + value_template: "{{ trigger.from_state.state == zone_state and trigger.to_state.state != zone_state }}" + +action: + domain: mobile_app + type: notify + device_id: !input notify_device + message: "{{ person_name }} has left {{ zone_state }}" diff --git a/etc/config/configuration.yaml b/etc/config/configuration.yaml new file mode 100644 index 0000000..fc6cc66 --- /dev/null +++ b/etc/config/configuration.yaml @@ -0,0 +1,64 @@ +homeassistant: + name: Home Assistant KR + latitude: !secret latitude_home + longitude: !secret longitude_home + elevation: !secret elevation_home + temperature_unit: C + time_zone: Europe/Moscow + unit_system: metric + +zone: + - name: Home + latitude: !secret latitude_home + longitude: !secret longitude_home + radius: 200 + icon: mdi:home + +default_config: + +tts: + - platform: google_translate + +automation: !include automations.yaml +group: !include groups.yaml +recorder: !include recorder.yaml +scene: !include scenes.yaml +script: !include scripts.yaml + +# https://www.home-assistant.io/integrations/yeelight +yeelight: + devices: + 10.10.10.10: + name: Room unit0 + transition: 1000 + use_music_mode: false + save_on_change: false + model: color4 + 10.10.10.11: + name: Room unit1 + transition: 1000 + use_music_mode: false + save_on_change: false + model: color4 + 10.10.10.12: + name: Room unit2 + transition: 1000 + use_music_mode: false + save_on_change: false + model: color4 + 10.10.10.13: + name: Room unit3 + transition: 1000 + use_music_mode: false + save_on_change: false + model: color4 + +# https://github.com/claytonjn/hass-circadian_lighting +circadian_lighting: +switch: + - platform: circadian_lighting + lights_ct: + - light.room_unit0 + - light.room_unit1 + - light.room_unit2 + - light.room_unit3 diff --git a/etc/config/custom_components/circadian_lighting/__init__.py b/etc/config/custom_components/circadian_lighting/__init__.py new file mode 100644 index 0000000..3acf047 --- /dev/null +++ b/etc/config/custom_components/circadian_lighting/__init__.py @@ -0,0 +1,316 @@ +""" +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 logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import ( + VALID_TRANSITION, ATTR_TRANSITION) +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_ELEVATION, + SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) +from homeassistant.util import Throttle +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_sunrise, track_sunset, track_time_change +from homeassistant.util.color import ( + color_temperature_to_rgb, color_RGB_to_xy, + color_xy_to_hs) +from homeassistant.util.dt import now as dt_now, get_time_zone + +from datetime import datetime, timedelta + +VERSION = '1.0.13' + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'circadian_lighting' +CIRCADIAN_LIGHTING_PLATFORMS = ['sensor', 'switch'] +CIRCADIAN_LIGHTING_UPDATE_TOPIC = '{0}_update'.format(DOMAIN) +DATA_CIRCADIAN_LIGHTING = 'data_cl' + +CONF_MIN_CT = 'min_colortemp' +DEFAULT_MIN_CT = 2500 +CONF_MAX_CT = 'max_colortemp' +DEFAULT_MAX_CT = 5500 +CONF_SUNRISE_OFFSET = 'sunrise_offset' +CONF_SUNSET_OFFSET = 'sunset_offset' +CONF_SUNRISE_TIME = 'sunrise_time' +CONF_SUNSET_TIME = 'sunset_time' +CONF_INTERVAL = 'interval' +DEFAULT_INTERVAL = 300 +DEFAULT_TRANSITION = 60 + +CONFIG_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.positive_int, + vol.Optional(ATTR_TRANSITION, default=DEFAULT_TRANSITION): VALID_TRANSITION + }), +}, extra=vol.ALLOW_EXTRA) + +def setup(hass, config): + """Set up the Circadian Lighting component.""" + conf = config[DOMAIN] + 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) + + load_platform(hass, 'sensor', DOMAIN, {}, config) + + interval = conf.get(CONF_INTERVAL) + transition = conf.get(ATTR_TRANSITION) + + cl = CircadianLighting(hass, min_colortemp, max_colortemp, + sunrise_offset, sunset_offset, sunrise_time, sunset_time, + latitude, longitude, elevation, + interval, transition) + + hass.data[DATA_CIRCADIAN_LIGHTING] = cl + + return True + +class CircadianLighting(object): + """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): + self.hass = hass + self.data = {} + self.data['min_colortemp'] = min_colortemp + self.data['max_colortemp'] = max_colortemp + self.data['sunrise_offset'] = sunrise_offset + self.data['sunset_offset'] = sunset_offset + self.data['sunrise_time'] = sunrise_time + self.data['sunset_time'] = sunset_time + self.data['latitude'] = latitude + self.data['longitude'] = longitude + self.data['elevation'] = elevation + self.data['interval'] = interval + self.data['transition'] = transition + self.data['timezone'] = self.get_timezone() + self.data['percent'] = self.calc_percent() + self.data['colortemp'] = self.calc_colortemp() + self.data['rgb_color'] = self.calc_rgb() + self.data['xy_color'] = self.calc_xy() + self.data['hs_color'] = self.calc_hs() + + self.update = Throttle(timedelta(seconds=interval))(self._update) + + if self.data['sunrise_time'] is not None: + track_time_change(self.hass, self._update, hour=int(self.data['sunrise_time'].strftime("%H")), minute=int(self.data['sunrise_time'].strftime("%M")), second=int(self.data['sunrise_time'].strftime("%S"))) + else: + track_sunrise(self.hass, self._update, self.data['sunrise_offset']) + if self.data['sunset_time'] is not None: + track_time_change(self.hass, self._update, hour=int(self.data['sunset_time'].strftime("%H")), minute=int(self.data['sunset_time'].strftime("%M")), second=int(self.data['sunset_time'].strftime("%S"))) + else: + track_sunset(self.hass, self._update, self.data['sunset_offset']) + + def get_timezone(self): + from timezonefinder import TimezoneFinder + tf = TimezoneFinder() + timezone_string = tf.timezone_at(lng=self.data['longitude'], lat=self.data['latitude']) + timezone = get_time_zone(timezone_string) + _LOGGER.debug("Timezone: " + str(timezone)) + return timezone + + def get_sunrise_sunset(self, date = None): + if self.data['sunrise_time'] is not None and self.data['sunset_time'] is not None: + if date is None: + date = dt_now(self.data['timezone']) + sunrise = date.replace(hour=int(self.data['sunrise_time'].strftime("%H")), minute=int(self.data['sunrise_time'].strftime("%M")), second=int(self.data['sunrise_time'].strftime("%S")), microsecond=int(self.data['sunrise_time'].strftime("%f"))) + sunset = date.replace(hour=int(self.data['sunset_time'].strftime("%H")), minute=int(self.data['sunset_time'].strftime("%M")), second=int(self.data['sunset_time'].strftime("%S")), microsecond=int(self.data['sunset_time'].strftime("%f"))) + solar_noon = sunrise + (sunset - sunrise)/2 + solar_midnight = sunset + ((sunrise + timedelta(days=1)) - sunset)/2 + else: + import astral + location = astral.Location() + location.name = 'name' + location.region = 'region' + location.latitude = self.data['latitude'] + location.longitude = self.data['longitude'] + location.elevation = self.data['elevation'] + _LOGGER.debug("Astral location: " + str(location)) + if self.data['sunrise_time'] is not None: + if date is None: + date = dt_now(self.data['timezone']) + sunrise = date.replace(hour=int(self.data['sunrise_time'].strftime("%H")), minute=int(self.data['sunrise_time'].strftime("%M")), second=int(self.data['sunrise_time'].strftime("%S")), microsecond=int(self.data['sunrise_time'].strftime("%f"))) + else: + sunrise = location.sunrise(date) + if self.data['sunset_time'] is not None: + if date is None: + date = dt_now(self.data['timezone']) + sunset = date.replace(hour=int(self.data['sunset_time'].strftime("%H")), minute=int(self.data['sunset_time'].strftime("%M")), second=int(self.data['sunset_time'].strftime("%S")), microsecond=int(self.data['sunset_time'].strftime("%f"))) + else: + sunset = location.sunset(date) + solar_noon = location.solar_noon(date) + solar_midnight = location.solar_midnight(date) + if self.data['sunrise_offset'] is not None: + sunrise = sunrise + self.data['sunrise_offset'] + if self.data['sunset_offset'] is not None: + sunset = sunset + self.data['sunset_offset'] + return { + SUN_EVENT_SUNRISE: sunrise.astimezone(self.data['timezone']), + SUN_EVENT_SUNSET: sunset.astimezone(self.data['timezone']), + 'solar_noon': solar_noon.astimezone(self.data['timezone']), + 'solar_midnight': solar_midnight.astimezone(self.data['timezone']) + } + + def calc_percent(self): + now = dt_now(self.data['timezone']) + _LOGGER.debug("now: " + str(now)) + + today_sun_times = self.get_sunrise_sunset(now) + _LOGGER.debug("today_sun_times: " + str(today_sun_times)) + + # Convert everything to epoch timestamps for easy calculation + now_seconds = now.timestamp() + sunrise_seconds = today_sun_times[SUN_EVENT_SUNRISE].timestamp() + sunset_seconds = today_sun_times[SUN_EVENT_SUNSET].timestamp() + solar_noon_seconds = today_sun_times['solar_noon'].timestamp() + solar_midnight_seconds = today_sun_times['solar_midnight'].timestamp() + + if now < today_sun_times[SUN_EVENT_SUNRISE]: # It's before sunrise (after midnight) + # Because it's before sunrise (and after midnight) sunset must have happend yesterday + yesterday_sun_times = self.get_sunrise_sunset(now - timedelta(days=1)) + _LOGGER.debug("yesterday_sun_times: " + str(yesterday_sun_times)) + sunset_seconds = yesterday_sun_times[SUN_EVENT_SUNSET].timestamp() + if today_sun_times['solar_midnight'] > today_sun_times[SUN_EVENT_SUNSET] and yesterday_sun_times['solar_midnight'] > yesterday_sun_times[SUN_EVENT_SUNSET]: + # Solar midnight is after sunset so use yesterdays's time + solar_midnight_seconds = yesterday_sun_times['solar_midnight'].timestamp() + elif now > today_sun_times[SUN_EVENT_SUNSET]: # It's after sunset (before midnight) + # Because it's after sunset (and before midnight) sunrise should happen tomorrow + tomorrow_sun_times = self.get_sunrise_sunset(now + timedelta(days=1)) + _LOGGER.debug("tomorrow_sun_times: " + str(tomorrow_sun_times)) + sunrise_seconds = tomorrow_sun_times[SUN_EVENT_SUNRISE].timestamp() + if today_sun_times['solar_midnight'] < today_sun_times[SUN_EVENT_SUNRISE] and tomorrow_sun_times['solar_midnight'] < tomorrow_sun_times[SUN_EVENT_SUNRISE]: + # Solar midnight is before sunrise so use tomorrow's time + solar_midnight_seconds = tomorrow_sun_times['solar_midnight'].timestamp() + + _LOGGER.debug("now_seconds: " + str(now_seconds)) + _LOGGER.debug("sunrise_seconds: " + str(sunrise_seconds)) + _LOGGER.debug("sunset_seconds: " + str(sunset_seconds)) + _LOGGER.debug("solar_midnight_seconds: " + str(solar_midnight_seconds)) + _LOGGER.debug("solar_noon_seconds: " + str(solar_noon_seconds)) + + # 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 (obviously) generating a different parabola for sunrise-sunset + + # sunrise-sunset parabola + if now_seconds > sunrise_seconds and now_seconds < sunset_seconds: + h = solar_noon_seconds + k = 100 + # parabola before solar_noon + if now_seconds < solar_noon_seconds: + x = sunrise_seconds + # parabola after solar_noon + else: + x = sunset_seconds + y = 0 + + # sunset_sunrise parabola + elif now_seconds > sunset_seconds and now_seconds < sunrise_seconds: + h = solar_midnight_seconds + k = -100 + # parabola before solar_midnight + if now_seconds < solar_midnight_seconds: + x = sunset_seconds + # parabola after solar_midnight + else: + x = sunrise_seconds + y = 0 + + a = (y-k)/(h-x)**2 + percentage = a*(now_seconds-h)**2+k + + _LOGGER.debug("h: " + str(h)) + _LOGGER.debug("k: " + str(k)) + _LOGGER.debug("x: " + str(x)) + _LOGGER.debug("y: " + str(y)) + _LOGGER.debug("a: " + str(a)) + _LOGGER.debug("percentage: " + str(percentage)) + + return percentage + + def calc_colortemp(self): + if self.data['percent'] > 0: + return ((self.data['max_colortemp'] - self.data['min_colortemp']) * (self.data['percent'] / 100)) + self.data['min_colortemp'] + else: + return self.data['min_colortemp'] + + def calc_rgb(self): + return color_temperature_to_rgb(self.data['colortemp']) + + def calc_xy(self): + rgb = self.calc_rgb() + iR = rgb[0] + iG = rgb[1] + iB = rgb[2] + + return color_RGB_to_xy(iR, iG, iB) + + def calc_hs(self): + xy = self.calc_xy() + vX = xy[0] + vY = xy[1] + + return color_xy_to_hs(vX, vY) + + def _update(self, *args, **kwargs): + """Update Circadian Values.""" + self.data['percent'] = self.calc_percent() + self.data['colortemp'] = self.calc_colortemp() + self.data['rgb_color'] = self.calc_rgb() + self.data['xy_color'] = self.calc_xy() + self.data['hs_color'] = self.calc_hs() + dispatcher_send(self.hass, CIRCADIAN_LIGHTING_UPDATE_TOPIC) + _LOGGER.debug("Circadian Lighting Component Updated") diff --git a/etc/config/custom_components/circadian_lighting/manifest.json b/etc/config/custom_components/circadian_lighting/manifest.json new file mode 100644 index 0000000..4832a25 --- /dev/null +++ b/etc/config/custom_components/circadian_lighting/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "circadian_lighting", + "name": "Circadian Lighting", + "documentation": "https://github.com/claytonjn/hass-circadian_lighting", + "dependencies": [], + "codeowners": ["@claytonjn"], + "requirements": ["timezonefinder==4.2.0"] +} diff --git a/etc/config/custom_components/circadian_lighting/sensor.py b/etc/config/custom_components/circadian_lighting/sensor.py new file mode 100644 index 0000000..756d579 --- /dev/null +++ b/etc/config/custom_components/circadian_lighting/sensor.py @@ -0,0 +1,104 @@ +""" +Circadian Lighting Sensor for Home-Assistant. +""" + +DEPENDENCIES = ['circadian_lighting'] + +import logging + +from custom_components.circadian_lighting import DOMAIN, CIRCADIAN_LIGHTING_UPDATE_TOPIC, DATA_CIRCADIAN_LIGHTING + +from homeassistant.helpers.dispatcher import dispatcher_connect +from homeassistant.helpers.entity import Entity + +import datetime + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:theme-light-dark' + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Circadian Lighting sensor.""" + cl = hass.data.get(DATA_CIRCADIAN_LIGHTING) + if cl: + cs = CircadianSensor(hass, cl) + add_devices([cs]) + + def update(call=None): + """Update component.""" + cl._update() + service_name = "values_update" + hass.services.register(DOMAIN, service_name, update) + return True + else: + return False + +class CircadianSensor(Entity): + """Representation of a Circadian Lighting sensor.""" + + def __init__(self, hass, cl): + """Initialize the Circadian Lighting sensor.""" + self._cl = cl + self._name = 'Circadian Values' + self._entity_id = 'sensor.circadian_values' + self._state = self._cl.data['percent'] + self._unit_of_measurement = '%' + self._icon = ICON + self._hs_color = self._cl.data['hs_color'] + self._attributes = {} + self._attributes['colortemp'] = self._cl.data['colortemp'] + self._attributes['rgb_color'] = self._cl.data['rgb_color'] + self._attributes['xy_color'] = self._cl.data['xy_color'] + + """Register callbacks.""" + dispatcher_connect(hass, CIRCADIAN_LIGHTING_UPDATE_TOPIC, self.update_sensor) + + @property + def entity_id(self): + """Return the entity ID of the sensor.""" + return self._entity_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def hs_color(self): + return self._hs_color + + @property + def device_state_attributes(self): + """Return the attributes of the sensor.""" + return self._attributes + + def update(self): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + self._cl.update() + + def update_sensor(self): + if self._cl.data is not None: + self._state = self._cl.data['percent'] + self._hs_color = self._cl.data['hs_color'] + self._attributes['colortemp'] = self._cl.data['colortemp'] + self._attributes['rgb_color'] = self._cl.data['rgb_color'] + self._attributes['xy_color'] = self._cl.data['xy_color'] + _LOGGER.debug("Circadian Lighting Sensor Updated") \ No newline at end of file diff --git a/etc/config/custom_components/circadian_lighting/services.yaml b/etc/config/custom_components/circadian_lighting/services.yaml new file mode 100644 index 0000000..586f06e --- /dev/null +++ b/etc/config/custom_components/circadian_lighting/services.yaml @@ -0,0 +1,2 @@ +values_update: + description: Updates values for Circadian Lighting. diff --git a/etc/config/custom_components/circadian_lighting/switch.py b/etc/config/custom_components/circadian_lighting/switch.py new file mode 100644 index 0000000..3b58c1c --- /dev/null +++ b/etc/config/custom_components/circadian_lighting/switch.py @@ -0,0 +1,362 @@ +""" +Circadian Lighting Switch for Home-Assistant. +""" + +DEPENDENCIES = ['circadian_lighting', 'light'] + +import logging + +from custom_components.circadian_lighting import DOMAIN, CIRCADIAN_LIGHTING_UPDATE_TOPIC, DATA_CIRCADIAN_LIGHTING + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_connect +from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.components.light import ( + is_on, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, + VALID_TRANSITION, ATTR_WHITE_VALUE, ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN) + +try: + from homeassistant.components.switch import SwitchEntity +except ImportError: + from homeassistant.components.switch import SwitchDevice as SwitchEntity + +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_NAME, CONF_PLATFORM, STATE_ON, + SERVICE_TURN_ON) +from homeassistant.util import slugify +from homeassistant.util.color import ( + color_RGB_to_xy, color_temperature_kelvin_to_mired, + color_temperature_to_rgb, color_xy_to_hs) + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:theme-light-dark' + +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 = 'min_brightness' +DEFAULT_MIN_BRIGHT = 1 +CONF_MAX_BRIGHT = 'max_brightness' +DEFAULT_MAX_BRIGHT = 100 +CONF_SLEEP_ENTITY = 'sleep_entity' +CONF_SLEEP_STATE = 'sleep_state' +CONF_SLEEP_CT = 'sleep_colortemp' +CONF_SLEEP_BRIGHT = 'sleep_brightness' +CONF_DISABLE_ENTITY = 'disable_entity' +CONF_DISABLE_STATE = 'disable_state' +CONF_INITIAL_TRANSITION = 'initial_transition' +DEFAULT_INITIAL_TRANSITION = 1 + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'circadian_lighting', + vol.Optional(CONF_NAME, default="Circadian Lighting"): cv.string, + 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): cv.string, + vol.Optional(CONF_SLEEP_CT): + vol.All(vol.Coerce(int), vol.Range(min=1000, max=10000)), + vol.Optional(CONF_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): cv.string, + vol.Optional(CONF_INITIAL_TRANSITION, default=DEFAULT_INITIAL_TRANSITION): + VALID_TRANSITION +}) + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Circadian Lighting switches.""" + cl = hass.data.get(DATA_CIRCADIAN_LIGHTING) + if cl: + 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) + name = config.get(CONF_NAME) + 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) + cs = CircadianSwitch(hass, cl, name, 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, initial_transition) + add_devices([cs]) + + def update(call=None): + """Update lights.""" + cs.update_switch() + return True + else: + return False + + +class CircadianSwitch(SwitchEntity, RestoreEntity): + """Representation of a Circadian Lighting switch.""" + + def __init__(self, hass, cl, name, 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, initial_transition): + """Initialize the Circadian Lighting switch.""" + self.hass = hass + self._cl = cl + self._name = name + self._entity_id = "switch." + slugify("{} {}".format('circadian_lighting', name)) + self._state = None + self._icon = ICON + self._hs_color = None + self._lights_ct = lights_ct + self._lights_rgb = lights_rgb + self._lights_xy = lights_xy + self._lights_brightness = lights_brightness + 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._attributes = {} + self._attributes['hs_color'] = self._hs_color + self._attributes['brightness'] = None + + self._lights = [] + if lights_ct != None: + self._lights += lights_ct + if lights_rgb != None: + self._lights += lights_rgb + if lights_xy != None: + self._lights += lights_xy + if lights_brightness != None: + self._lights += lights_brightness + + """Register callbacks.""" + dispatcher_connect(hass, CIRCADIAN_LIGHTING_UPDATE_TOPIC, self.update_switch) + track_state_change(hass, self._lights, self.light_state_changed) + if self._sleep_entity is not None: + track_state_change(hass, self._sleep_entity, self.sleep_state_changed) + if self._disable_entity is not None: + track_state_change(hass, self._disable_entity, self.disable_state_changed) + + @property + def entity_id(self): + """Return the entity ID of the switch.""" + return self._entity_id + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if circadian lighting is on.""" + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + if self._state is not None: + return + + state = await self.async_get_last_state() + self._state = state and state.state == STATE_ON + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def hs_color(self): + return self._hs_color + + @property + def device_state_attributes(self): + """Return the attributes of the switch.""" + return self._attributes + + def turn_on(self, **kwargs): + """Turn on circadian lighting.""" + self._state = True + + # Make initial update + self.update_switch(self._initial_transition) + + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn off circadian lighting.""" + self._state = False + self.schedule_update_ha_state() + self._hs_color = None + self._attributes['hs_color'] = self._hs_color + self._attributes['brightness'] = None + + def is_sleep(self): + return self._sleep_entity is not None and self.hass.states.get(self._sleep_entity).state == self._sleep_state + + def calc_ct(self): + if self.is_sleep(): + _LOGGER.debug(self._name + " in Sleep mode") + return color_temperature_kelvin_to_mired(self._sleep_colortemp) + else: + return color_temperature_kelvin_to_mired(self._cl.data['colortemp']) + + def calc_rgb(self): + if self.is_sleep(): + _LOGGER.debug(self._name + " in Sleep mode") + return color_temperature_to_rgb(self._sleep_colortemp) + else: + return color_temperature_to_rgb(self._cl.data['colortemp']) + + 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_brightness(self): + if self._disable_brightness_adjust is True: + return None + else: + if self.is_sleep(): + _LOGGER.debug(self._name + " in Sleep mode") + return self._sleep_brightness + else: + if self._cl.data['percent'] > 0: + return self._max_brightness + else: + return ((self._max_brightness - self._min_brightness) * ((100+self._cl.data['percent']) / 100)) + self._min_brightness + + def update_switch(self, transition=None): + if self._cl.data is not None: + self._hs_color = self.calc_hs() + self._attributes['hs_color'] = self._hs_color + self._attributes['brightness'] = self.calc_brightness() + _LOGGER.debug(self._name + " Switch Updated") + + self.adjust_lights(self._lights, transition) + + def should_adjust(self): + if self._state is not True: + _LOGGER.debug(self._name + " off - not adjusting") + return False + elif self._cl.data is None: + _LOGGER.debug(self._name + " could not retrieve Circadian Lighting data") + return False + elif self._disable_entity is not None and self.hass.states.get(self._disable_entity).state == self._disable_state: + _LOGGER.debug(self._name + " disabled by " + str(self._disable_entity)) + return False + else: + return True + + def adjust_lights(self, lights, transition=None): + if self.should_adjust(): + if transition == None: + transition = self._cl.data['transition'] + + brightness = int((self._attributes['brightness'] / 100) * 254) if self._attributes['brightness'] is not None else None + mired = int(self.calc_ct()) if self._lights_ct is not None else None + rgb = tuple(map(int, self.calc_rgb())) if self._lights_rgb is not None else None + xy = self.calc_xy() if self._lights_xy is not None else None + + for light in lights: + """Set color of array of ct light if on.""" + if self._lights_ct is not None and light in self._lights_ct and is_on(self.hass, light): + service_data = {ATTR_ENTITY_ID: light} + if mired is not None: + service_data[ATTR_COLOR_TEMP] = mired + if brightness is not None: + service_data[ATTR_BRIGHTNESS] = brightness + if transition is not None: + service_data[ATTR_TRANSITION] = transition + self.hass.services.call( + LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) + _LOGGER.debug(light + " CT Adjusted - color_temp: " + str(mired) + ", brightness: " + str(brightness) + ", transition: " + str(transition)) + + """Set color of array of rgb light if on.""" + if self._lights_rgb is not None and light in self._lights_rgb and is_on(self.hass, light): + service_data = {ATTR_ENTITY_ID: light} + if rgb is not None: + service_data[ATTR_RGB_COLOR] = rgb + if brightness is not None: + service_data[ATTR_BRIGHTNESS] = brightness + if transition is not None: + service_data[ATTR_TRANSITION] = transition + self.hass.services.call( + LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) + _LOGGER.debug(light + " RGB Adjusted - rgb_color: " + str(rgb) + ", brightness: " + str(brightness) + ", transition: " + str(transition)) + + """Set color of array of xy light if on.""" + if self._lights_xy is not None and light in self._lights_xy and is_on(self.hass, light): + service_data = {ATTR_ENTITY_ID: light} + if xy is not None: + service_data[ATTR_XY_COLOR] = xy + if brightness is not None: + service_data[ATTR_BRIGHTNESS] = brightness + service_data[ATTR_WHITE_VALUE] = brightness + if transition is not None: + service_data[ATTR_TRANSITION] = transition + self.hass.services.call( + LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) + _LOGGER.debug(light + " XY Adjusted - xy_color: " + str(xy) + ", brightness: " + str(brightness) + ", transition: " + str(transition) + ", white_value: " + str(brightness)) + + """Set color of array of brightness light if on.""" + if self._lights_brightness is not None and light in self._lights_brightness and is_on(self.hass, light): + service_data = {ATTR_ENTITY_ID: light} + if brightness is not None: + service_data[ATTR_BRIGHTNESS] = brightness + if transition is not None: + service_data[ATTR_TRANSITION] = transition + self.hass.services.call( + LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) + _LOGGER.debug(light + " Brightness Adjusted - brightness: " + str(brightness) + ", transition: " + str(transition)) + + def light_state_changed(self, entity_id, from_state, to_state): + try: + _LOGGER.debug(entity_id + " change from " + str(from_state) + " to " + str(to_state)) + if to_state.state == 'on' and from_state.state != 'on': + self.adjust_lights([entity_id], self._initial_transition) + except: + pass + + def sleep_state_changed(self, entity_id, from_state, to_state): + try: + _LOGGER.debug(entity_id + " change from " + str(from_state) + " to " + str(to_state)) + if to_state.state == self._sleep_state or from_state.state == self._sleep_state: + self.update_switch(self._initial_transition) + except: + pass + + def disable_state_changed(self, entity_id, from_state, to_state): + try: + _LOGGER.debug(entity_id + " change from " + str(from_state) + " to " + str(to_state)) + if from_state.state == self._disable_state: + self.update_switch(self._initial_transition) + except: + pass diff --git a/etc/config/groups.yaml b/etc/config/groups.yaml new file mode 100644 index 0000000..e69de29 diff --git a/etc/config/recorder.yaml b/etc/config/recorder.yaml new file mode 100644 index 0000000..241f71a --- /dev/null +++ b/etc/config/recorder.yaml @@ -0,0 +1 @@ +db_url: !secret recorder_db_url diff --git a/etc/config/scenes.yaml b/etc/config/scenes.yaml new file mode 100644 index 0000000..e69de29 diff --git a/etc/config/scripts.yaml b/etc/config/scripts.yaml new file mode 100644 index 0000000..e69de29