App #2: Smart Light

Tags: #<Tag:0x00007f7c60ebb818>

Hi guys!

Here is another automation using AppDaemon :grinning:

This automation is not final or complete, and probably there is a way to make more generic. My aim is to share examples that maybe can help new members starting with HA+AppDaemon.

Suggestions or recommendations to improve the implementation are welcome!

App #2: Smart Light
Turn on the light of the bathroom if a movement is detected and the ambient light is less than 40 lux.
Turn off light of the bathroom after two minutes without detecting any movement.

Entities

sensor.bathroom_ambient_light
sensor.bathroom_motion
light.bathroom_lamp

app.yaml

bathroom_light:
  module: bathroom_smart_light
  class: BathroomSmartLight  
  sensors:
    motion: binary_sensor.bathroom_motion
    light: sensor.bathroom_ambient_light
  lights:
    bathroom: light.bathroom_lamp

bathroom_smart_light.py

import appdaemon.plugins.hass.hassapi as hass

class BathroomSmartLight(hass.Hass):

    def initialize(self):
        self.log('initializing ...')
        self.timer = None
        self.listen_state(self.on_motion_detected, self.args["sensors"]["motion"], new="on") 

    def on_motion_detected(self, entity, attribute, old, new, kwargs):
        light_entity = self.args["lights"]["bathroom"]
        ambient_light = int(self.get_state(self.args["sensors"]["light"]))
        bathroom_light = self.get_state(light_entity)
        if ambient_light <= 35 and bathroom_light == "off":
            self.log('turning on the bathroom light')
            self.turn_on(light_entity)
            self.__set_timer()
        elif bathroom_light == "on":
            self.log('Light already ON ...') 
            self.__set_timer()   


    def __start_timer(self):
        self.timer = self.run_in(self.__turn_off_light, 10)

    def __set_timer(self): 
        if self.timer == None:
            self.log('starting timer ...')   
            self.__start_timer()
        else:
            self.log('rescheduling timer ...')  
            self.cancel_timer(self.timer)  
            self.__start_timer()  

    def __turn_off_light(self, kwargs):
        self.log('Timer expired, turning off the light ...')  
        self.turn_off(self.args["lights"]["bathroom"])                  

I hope this can be useful for someone :wink:

Happy coding!
Humberto

Edit #1 : Adopt a more pythonic naming convention and other suggestions received from @SupahNoob

Edit #2 Avoid hardcode entities names in the automation code. Thanks @swiftlyfalling @Burningstone

Previous post: App #1: Doorbell notification
Next post: App #3: Smart Radiator

1 Like

Curious, does your motion sensor bathroom_motion register multiple ONs? ie, if motion happens consistently over a period of 10 seconds, will it keep sending ON, ON, ON over and over? I would probably approach this problem a bit differently instead of using “private” methods and storing the ambient light (since you can grab the state of a given entity at will already!).

1 Like

This is a great start.

For me, when I write something like this, I know I’ll use it again. And I also know, at some point, Home Assistant is going to break, or my sensor will break, or something will go wrong. So, I like to make the code as generic and as configurable as possible, and I like to validate everything to ensure everything is what I expect it to be. So, if I were writing this, I’d change it a bit:

import appdaemon.plugins.hass.hassapi as hass

# app.yaml
# bathroom_motion_light:
#   module: smart_light
#   class: SmartLight
#   light_entity: light.bathroom
#   lux_entity: sensor.bathroom_ambient_light
#   motion_entity: sensor.bathroom_motion
#   lux_limit: 40
#   light_timeout: 5

class SmartLight(hass.Hass):

    def initialize(self):
        self.log('initializing')

        try:
            self._light_entity = self.args.get('light_entity', None)
            self._motion_entity = self.args.get('motion_entity', None)
            self._lux_entity = self.args.get('lux_entity', None)
            self._lux_limit = int(self.args.get('lux_limit', 40))
            self._light_timeout = int(self.args.get('light_timeout', 5))
        except (TypeError, ValueError):
            self.log("Invalid Configuration", level="ERROR")
            return

        if None in [
                self._light_entity,
                self._motion_entity,
                self._lux_entity]:

            self.log('Incomplete Configuration', level="ERROR")
            return
            
        self._timer = None
        self.listen_state(self.motion_detected, self._motion_entity, new="ON") 

    def motion_detected(self, entity, attribute, old, new, kwargs):
        try:
            ambient_light = int(self.get_state(self._lux_entity))
        except (ValueError, TypeError):
            self.log("Could not get Ambient Light Level", level="WARNING")
            return

        light_state = int(self.get_state(self._light_entity))
        if light_state not in ['OFF', 'ON']:
            self.log("Invalid Light State: {}".format(light_state),
                level="WARNING"))
            return
    
        if ambient_light <= self._lux_limit and light_state == "OFF":
            self.log('Turning ON Light')
            self.turn_on(self._light_entity)
            self.start_timer()
        elif light_state == "ON":
            self.log('Light already ON') 
            self.start_timer()   

    def start_timer(self):
        self.cancel_timer(self._timer)
        self._timer = self.run_in(self.turn_off_light, self._light_timeout)

    def turn_off_light(self, kwargs):
        self.log('Timer expired, turning OFF the light')
        self._timer = None
        self.turn_off(self._light_entity)

Untested code, so there may be typos.

Once it was nice and generic, and I started using it, I’d probably quickly realize a few things…

  1. I may want to turn on/off more than one light. So I’d change light_entity to also accept a list.
  2. Perhaps some rooms have more than one motion sensor?
  3. If your lights have configurable levels, perhaps you’d like to add a constraint that checks if you’re sleeping (based on time, or some switch you flip or whatever). Add a “brightness_level” option and have two versions of this app running: one for when you’re sleeping (with a brightness of 16, perhaps, and a constraint on your sleep mode being “on”) and one for when you’re awake (with a brightness of, 255, perhaps and a constraint on your sleep mode being “off”).

That’s the beautiful thing about AppDaemon. You can write what you wrote and it works and you’re good to go and there’s nothing wrong with that. And you can just write code (even if some pieces look the same as others) for every light you might want to control like this. And everything is great. Or you can make it generic and reuse it. It works either way. And, while one is a bit more friendly to yourself and others if you have a lot of lights, neither is “better” because they both get the job done.

2 Likes

Hello @SupahNoob

The motion sensor after detects movement it goes to sleep (e.g., 1 min), if when it wakes up it detects movement again you can receive multiples ON, ON. So, whenever the motion sensor emits ON and the timer is running I want to reschedule the timer.

Thanks for your recommendation :+1:

Hi @swiftlyfalling

Indeed, you are right this kind of automation should be more generic since you can apply the same behavior in multiple rooms. I forgot to put some note about that :slightly_frowning_face:.

My idea was to extend this automation in a future post to make it generic. But, many thanks for sharing your generic solution :+1:

I will try it later :+1: and I will let you know :slightly_smiling_face:

1 Like

Hey,
I used this code example as a base to add more functionality and also fixed a few things. To summarize all the features:

  • turn on/off light after motion is detected and timeout occurred
  • timeout can be specified (used AppDaemon internal timer) or a HA countdown entity can be used (easier to show remaining time in HA)
  • entity to measure illuminance and threshold can be specified to keep the lights off if there is already enough light in the room
  • support for multiple motion sensors
  • entity to disable smartlights can be passed
  • validation checks
  • re-start countdown at startup if lights are already on (can happen if a motion is detected, light turns on and then you restart HA before lights go off)

Here is the code:

import appdaemon.plugins.hass.hassapi as hass
import appdaemon.plugins.hass
import threading

"""
App to automatically turn lights on/off for a certain time depending on the input of one or multiple motions sensors.

Args:
    light_entity - the light/switch that shall be controlled
    motion_entities - a list of motion sensors
    lux_entity (optional) - the sensor that returns the current illuminance 
    lux_limit (optional) - the light will not be turned on if the lux_entity returns a value smaller than the lux_limit
    light_timeout (optional) - the time in seconds after which the lights shall be turned off. Each motion event restarts the timer
    countdown_entity (optional) - a timer entity that can be used for the timeout handling. If this entity is set, the light_timeout argument will be ignored, because the entity already has its own timeout
    disable_motion_sensor_entity - a switch entity that can enable/disable the automatic light on/off handling
"""

class SmartLight(hass.Hass):

    def initialize(self):
        self.__single_threading_lock = threading.Lock()

        try:
            self._light_entity = self.args.get('light_entity', None)
            self._motion_entities = self.args.get('motion_entities', None)
            self._lux_entity = self.args.get('lux_entity', None)
            self._lux_limit = int(self.args.get('lux_limit', 0))
            self._light_timeout = int(self.args.get('light_timeout', 0))
            self._countdown_entity = self.args.get('countdown_entity', None)
            self._disable_motion_sensor_entity = self.args.get('disable_motion_sensor_entity', None)
        except (TypeError, ValueError):
            self.log("Invalid Configuration", level="ERROR")
            return

        if type(self._motion_entities) is not list:
            self.log('motion_entities must be a list of entities', level="ERROR")

        if self._light_entity is None:
            self.log('light entity missing.', level="ERROR")
            return

        if self._lux_limit > 0 and self._lux_entity is None:
            self.log('lux_limit given but no lux_entity specified', level="ERROR")
            return

        if self._countdown_entity is not None and self._light_timeout > 0:
            self.log('light_timeout will be ignored, because a countdown_entity is given. The timeout of the entity '+self._countdown_entity+ " will be used instead.", level="WARNING")

        if self._countdown_entity is None and self._light_timeout == 0:
            self.log('No timeout given. 5s will be used by default.', level="WARNING")
            self._light_timeout = 5

        self._timer = None
        if self._countdown_entity is not None:
            self.listen_event(self.turn_off_light, "timer.finished", entity_id = self._countdown_entity)
        if self.get_state(self._light_entity) == "on":
            self.log('light ' + self._light_entity + " is already on.")
            self.restart_timer()
        for motion_entity in self._motion_entities:
            self.listen_state(self.motion_detected_callback, motion_entity, new="on")
        if self._disable_motion_sensor_entity is not None:
            self.listen_state(self.motion_sensor_disabled_callback, self._disable_motion_sensor_entity)
        self.log('initialized')

    def is_motion_sensor_disabled(self):
        if self._disable_motion_sensor_entity is not None:
            return self.get_state(self._disable_motion_sensor_entity) == "on"
        return False 

    def motion_sensor_disabled_callback(self, entity, attribute, old, new, kwargs):
        self.log("Smartlights disabled: {}".format(self.is_motion_sensor_disabled()))
        if self.is_motion_sensor_disabled():
            self.stop_timer()
        else:
            self.restart_timer()

    def motion_detected_callback(self, entity, attribute, old, new, kwargs):
        self.motion_detected()

    def motion_detected(self):
        with self.__single_threading_lock: # prevents code from parallel execution e.g. due to multiple consecutive events
            self.log("Motion detected")
            if self.is_motion_sensor_disabled():
                self.log("... but motion sensor disabled")
                return
            if self._lux_limit > 0:
                try:
                    ambient_light = int(self.get_state(self._lux_entity))
                    self.log("Ambient light received: {} (threshold: {})".format(ambient_light, self._lux_limit))
                except (ValueError, TypeError):
                    self.log("Could not get Ambient Light Level", level="WARNING")
                    return

            light_state = self.get_state(self._light_entity)
            if light_state not in ['off', 'on']:
                self.log("Invalid Light State: {}. Expecting either on or off.".format(light_state), level="WARNING")
                return
        
            if (self._lux_limit == 0 or ambient_light <= self._lux_limit) and light_state == "off":
                self.turn_on_light()
                self.restart_timer()
            elif light_state == "on":
                self.log('Light already ON') 
                self.restart_timer()

    def stop_timer(self):
        if self._countdown_entity is not None:
            self.call_service("timer/cancel", entity_id = self._countdown_entity)
        else:
            self.cancel_timer(self._timer)

    def start_timer(self):
        if self._countdown_entity is not None:
            self.call_service("timer/start", entity_id  = self._countdown_entity)
        else:
            self._timer = self.run_in(self.turn_off_light, self._light_timeout)

    def restart_timer(self):
        if self.is_motion_sensor_disabled():
            self.log('Timer not restarted because smartlight disabled')
            return
        self.log('Restart timer')
        self.stop_timer()
        self.start_timer()

    def turn_on_light(self):
        self.log('Turning light on')
        self.turn_on(self._light_entity)

    def turn_off_light(self, event_name = None, data = None, kwargs = None):
        self.log('Timer expired')
        self._timer = None
        self.turn_off(self._light_entity)

Input configuration can be as simple as:

KitchenMotionLight:
  module: smartlight
  class: SmartLight
  light_entity: light.kitchen_lightstripe
  motion_entities:
    - binary_sensor.kitchen_movement_occupancy
  light_timeout: 300 #uses AppDaemon internal timer

or a bit more complex:

DiningRoomMotionLight:
  module: smartlight
  class: SmartLight
  light_entity: light.dining_room
  motion_entities: 
    - binary_sensor.dining_room_movement1_occupancy
    - binary_sensor.dining_room_movement2_occupancy
  lux_limit: 17000
  countdown_entity: timer.turn_off_dining_room_lights #uses HA countdown entity
  disable_motion_sensor_entity: input_boolean.disable_dining_room_motion
2 Likes