Advanced automated blind control based on the sun's position

There are several excellent templates and tutorials available for automating blinds to prevent the sun from directly hitting your workspace, like this great tutorial. I’ve developed a more advanced version using AppDaemon. Though it requires a bit more effort to set up, it offers a significantly more precise solution that feels almost magical. This method is adaptable to various setups with multiple windows and obstacles that should avoid direct sunlight.

The Idea:
Model all windows and obstacles (such as displays, TVs, and seating positions) as planes. Determine the position of the sun and calculate its rays for each window, from top to bottom. For each calculated ray, check if it intersects with any obstacles. Adjust the shades to eliminate any collisions between the sun’s rays and the obstacles.

Example:
In my setup, I modeled three displays, a seating position, and three windows. The red lines in the diagram represent the sun’s rays at the top corners of the windows, which are checked for collisions with the obstacles. The code calculates many more rays beyond these initial ones.
image
Currently, it’s very cloudy outside. Once the sun shines for a whole day, I’ll create a time-lapse video to showcase this in action :slight_smile:

Prerequisites:

  • Motorized roller shades with control in percent
  • Measuring tape
  • Basic Python and Linear Algebra knowledge
  • A relatively powerful processor. I run the code on an old Intel Core i5, and the calculations for 3 windows and 4 obstacles take up to 15 seconds. For larger setups or slower processors, you may need to optimize the code or reduce the number of calculated points.

The code and all steps (unfold the subitems):

My code. You will need to modify it.
import appdaemon.plugins.hass.hassapi as hass
import subprocess
import numpy as np
from scipy.optimize import fsolve
from datetime import datetime

class RollerShadeAutomatic(hass.Hass):
    def initialize(self):
        self.log("Roller Shade App started")
        self.rollerShadeCal = RollerShadePositionCalculator()

        # Run this script every minute
        self.run_every(self.close_roller_shades_automatically, datetime.now(), 60)
        self.log("Scheduled shade closing every minute")

        # Event handler
        self.listen_event(self.close_roller_shades_automatically, "TRIGGER_ROLLER_SHADE_CALC")
        self.log("Event listener for 'TRIGGER_ROLLER_SHADE_CALC' set up")

    def close_roller_shades_automatically(self, event_name=None, data=None, kwargs=None):
        self.log('Triggered shade calculation')

        avoiding_obstacles = []

        if self.get_state("input_boolean.sonne_blocken_sitzposition_aktiv") == 'on':
            avoiding_obstacles.append(ObjectPositions.SEATING_POSITION_PLANE)
        if self.get_state("input_boolean.sonne_blocken_bildschirm_aktiv") == 'on':
            avoiding_obstacles.append(ObjectPositions.MAIN_MONITOR_PLANE)
            avoiding_obstacles.append(ObjectPositions.SECOND_MONITOR_PLANE)
            avoiding_obstacles.append(ObjectPositions.LAPTOP_MONITOR_PLANE)

        # Turn off automatic mode when sun sets
        if self.get_state("sun.sun") == 'below_horizon':
            if self.get_state("input_boolean.sonne_blocken_bildschirm_aktiv") == 'on' or self.get_state("input_boolean.sonne_blocken_sitzposition_aktiv") == 'on':
                self.call_service("input_boolean/turn_off", entity_id="input_boolean.sonne_blocken_bildschirm_aktiv")
                self.call_service("input_boolean/turn_off", entity_id="input_boolean.sonne_blocken_sitzposition_aktiv")
                # close or open shades based on their current position
                if self.get_state("cover.rollo_alle") < 5:
                    self.call_service("cover/set_cover_position", entity_id="cover.rollo_alle", position=1)
                else:
                    self.call_service("cover/open_cover", entity_id="cover.rollo_alle")

        # Do not execute when nobody is at the desk or if both input booleans are off
        if self.get_state("binary_sensor.anwesenheit_schreibtisch") == 'off' or len(avoiding_obstacles) == 0:
            self.log("No one at desk or no obstacles to avoid, exiting")
            return

        rollerShadeCal = RollerShadePositionCalculator()
        rollerShadePositions = rollerShadeCal.get_positions_for_all_three_windows(avoiding_obstacles, self.get_sun_vector())

        positions_percent_left = self.calculate_positions_percent(rollerShadePositions[0])
        positions_percent_middle = self.calculate_positions_percent(rollerShadePositions[1])
        positions_percent_right = self.calculate_positions_percent(rollerShadePositions[2])

        self.log(f'Left: {positions_percent_left}, Middle: {positions_percent_middle}, Right: {positions_percent_right}')

        self.call_service("cover/set_cover_position", entity_id="cover.rollo_links", position=positions_percent_left)
        self.call_service("cover/set_cover_position", entity_id="cover.rollo_mitte", position=positions_percent_middle)
        self.call_service("cover/set_cover_position", entity_id="cover.rollo_rechts", position=positions_percent_right)

    def get_sun_vector(self):
        azimuth = np.radians(self.get_state("sun.sun", attribute="azimuth"))
        elevation = np.radians(self.get_state("sun.sun", attribute="elevation"))
        self.log(f"Sun vector - Azimuth: {np.degrees(azimuth)}, Elevation: {np.degrees(elevation)}")

        # this is for a coordinate system where the x-Axis is South and the y-Axis is East
        return np.array(
            [np.cos(elevation) * np.cos(azimuth),
            - np.cos(elevation) * np.sin(azimuth),
            - np.sin(elevation)]
        )

    # Allow positions between 1% and 97% (98% - 100% don't cover the window, 0% produces bug with my shades)
    def calculate_positions_percent(self, position):
        value = 100 - position * 100
        if value == 100:
            return 100
        else:
            calculated_position = int(1 + (96 / 99) * (value - 1))
            return calculated_position


class ObjectPositions:
    SUPPORT_VECTOR_LEFT_WINDOW_TOP_L = np.array([197.88 + 70.06, 6.91 + 2.48, 12 + 71])
    SUPPORT_VECTOR_LEFT_WINDOW_TOP_R = np.array([197.88, 6.91, 12 + 71])
    SUPPORT_VECTOR_MIDDLE_WINDOW_TOP_L = np.array([105.94 + 73.95, 3.70 + 2.58, 12 + 71])
    SUPPORT_VECTOR_MIDDLE_WINDOW_TOP_R = np.array([105.94, 3.70, 12 + 71])
    SUPPORT_VECTOR_RIGHT_WINDOW_TOP_L = np.array([16.99 + 70.06, 0.59 + 2.48, 12 + 71])
    SUPPORT_VECTOR_RIGHT_WINDOW_TOP_R = np.array([16.99, 0.59, 12 + 71])

    LEFT_WINDOW_PLANE = Plane(np.array([197.88, 6.91, 12]), np.array([0, 0, 71]), np.array([70.06, 2.48, 0]),
                                SUPPORT_VECTOR_LEFT_WINDOW_TOP_L,
                                SUPPORT_VECTOR_LEFT_WINDOW_TOP_R)
    MIDDLE_WINDOW_PLANE = Plane(np.array([105.94, 3.70, 12]), np.array([0, 0, 71]), np.array([73.95, 2.58, 0]),
                                SUPPORT_VECTOR_MIDDLE_WINDOW_TOP_L,
                                SUPPORT_VECTOR_MIDDLE_WINDOW_TOP_R)
    RIGHT_WINDOW_PLANE = Plane(np.array([16.99, 0.59, 12]), np.array([0, 0, 71]), np.array([70.06, 2.48, 0]),
                                SUPPORT_VECTOR_RIGHT_WINDOW_TOP_L,
                                SUPPORT_VECTOR_RIGHT_WINDOW_TOP_R)

    SEATING_POSITION_PLANE = Plane(np.array([80, 80, -50]), np.array([0, 0, 85]), np.array([30, 0, 0]))

    MAIN_MONITOR_PLANE = Plane(np.array([17.2, 50.97, -1]), np.array([0, 0, 29]), np.array([-1.88, 53.97, 0]))
    SECOND_MONITOR_PLANE = Plane(np.array([15.27, 106.93, -17]), np.array([0, 0, 53]), np.array([-1.05, 29.98, 0]))
    LAPTOP_MONITOR_PLANE = Plane(np.array([27.2, 61, -21]), np.array([-3, 0, 18]), np.array([-1.08, 31, 0]))


class RollerShadePositionCalculator:
    def get_positions_for_all_three_windows(self, list_of_avoiding_obstacle_planes, sun_vector):
        left_window = self.get_shade_position(ObjectPositions.LEFT_WINDOW_PLANE, list_of_avoiding_obstacle_planes, sun_vector)
        middle_window = self.get_shade_position(ObjectPositions.MIDDLE_WINDOW_PLANE, list_of_avoiding_obstacle_planes, sun_vector)
        right_window = self.get_shade_position(ObjectPositions.RIGHT_WINDOW_PLANE, list_of_avoiding_obstacle_planes, sun_vector)

        # If more than two windows are completely covered, open the left window
        # Makes sure that there is always natural light in the room
        if left_window + middle_window + right_window > 2:
            left_window = 0

        return [left_window, middle_window, right_window]

    def get_shade_position(self, window_plane, list_of_avoiding_obstacle_planes, sun_vector):
        lowest_collision = 0

        # All of my windows are 71cm in height
        window_height = 71

        # Start from top to bottom (window height) and give back pos for last intersection with obstacle planes
        # 0 is top of the window, 71 is bottom
        for i in range(window_height + 1):
            # For 100 width points of the window
            for j in range(101):
                width_position = window_plane.support_point_top_l + (j / 100) * (
                        window_plane.support_point_top_r - window_plane.support_point_top_l)
                width_position[2] -= i
                sun_line = Line(width_position, sun_vector)
                for obstacle_plane in list_of_avoiding_obstacle_planes:
                    if IntersectionCalculator(sun_line, obstacle_plane).calculate_intersection():
                        lowest_collision = i
                        break  # Break if a collision is found, no need to check other obstacle planes
                if lowest_collision == i:
                    break

        # Convert in percentage closed
        return lowest_collision / window_height


class Line:
    def __init__(self, point, direction):
        self.point = np.array(point)
        self.direction = np.array(direction)

    def get_point(self, t):
        return self.point + t * self.direction


class Plane:
    def __init__(self, point, direction1, direction2, support_point_top_l=None, support_point_top_r=None):
        self.point = np.array(point)
        self.direction1 = np.array(direction1)
        self.direction2 = np.array(direction2)
        self.support_point_top_l = support_point_top_l
        self.support_point_top_r = support_point_top_r

    def get_point(self, s, r):
        return self.point + s * self.direction1 + r * self.direction2


class IntersectionCalculator:
    def __init__(self, line, plane):
        self.line = line
        self.plane = plane

    def calculate_intersection(self):
        def equations(vars):
            t, s, r = vars
            line_point = self.line.get_point(t)
            plane_point = self.plane.get_point(s, r)
            return [line_point[0] - plane_point[0], line_point[1] - plane_point[1], line_point[2] - plane_point[2]]

        solution = fsolve(equations, (0, 0, 0))

        t = solution[0]
        s = solution[1]
        r = solution[2]

        if 0 <= s <= 1 and 0 <= r <= 1 and t >= 0:
            return True
        else:
            return False
Step 1: Set up an AppDaemon App
  • Set up an AppDaemon App with the given code. If you don’t use AppDaemon yet, follow their great documentation.
  • You need to add the python packages datetime, numpy==1.26.4 and scipy in the configuration of the addon. (There are some issues with numpy v2.0 atm, so just use the older version)
Step 2: Define a coordinate system

First, start by establishing a coordinate system. In my approach, I’ve set South as the x-axis, East as the y-axis, and up as the z-axis. This step is crucial because the sun’s position is represented by two angles: elevation and azimuth, which must be converted into a vector. If you opt for a different system (like using North as the x-axis), you’ll need to adjust the get_sun_vector method in my code accordingly.

Step 3: Get the orientation of your room

You can find out the orientation of your room with this tool:
https://osmcompass.com/
Coose “Draw Single Leg Route”, find your house on the map and then rotate the compass to match your windows. In the top right corner, the clockwise angle from north is shown.

Step 4: Model all obstacles as planes

Next, model all obstacles as planes and store them in the ObjectPositions class. Additionally, adjust the height of the window’s in the RollerShadePositionCalculator function.

It’s advisable to sketch the objects and their orientations, taking into account the defined coordinate system and the room’s angle you’ve determined. The unit of measurement used (centimeters in my case) is flexible.

Choose any point as the origin for your coordinate system. Since we’re describing objects within the room using a parametric plane definition, limit the planes accordingly:
r = r_0 + s⋅v + t⋅w

  • r_0 should be the position vector of the bottom-left corner of the window (only the glass!) or obstacle.
  • v should be the vector from the bottom-left corner to the bottom-right corner.
  • w should be the vector from the bottom-left corner to the top-left corner.

This ensures that every position on the window or obstacle can be reached by setting the s and t paramters between 0 and 1. Also, define both top corners in the ObjectPositions class.

All 7 planes I defined in my code are visualized here:
image

Step 5: Choose how you want to use the automation
  • Consider customizing the close_roller_shades_automatically method as per your requirements.
  • Currently, the method operates as follows, detailing how I implement automation:
    • I utilize two input_booleans: one for seating positions and another for displays. If both are off, no action is taken. If either or both are on, the corresponding obstacle (displays or seating) is added to the list of obstacles to avoid.
    • When the sun sets, these input_booleans are automatically turned off.
    • If at least one input_boolean remains on, I compute the positions for all shades and then utilize the Home Assistant service cover/set_cover_position to adjust their positions accordingly.
    • The calculation occurs every minute or can be manually triggered by sending the event TRIGGER_ROLLER_SHADE_CALC. I trigger this event in a standard Home Assistant automation whenever an input_boolean is activated.
  • You may consider adjusting the calculate_positions_percent method for the following reasons: Currently, when I set my shades to 2% closed, they do not cover the window glass adequately. Conversely, setting them to 100% closed causes a bug in my setup. To resolve this, I implemented a linear function ensuring the shades’ positions remain between 3% and 99% closed. You should adjust this function according to your specific setup.

That’s it – I hope this works for you!

Nice job :slight_smile:

Were you aware that the blueprint you linked has since become an integration called Adaptive Cover that is fairly powerful? You might find inspiration there for enhancements to your own AppDaemon script.

I haven’t seen that yet, thanks! :slight_smile:
Looks very nice and has some cool native features - but isn’t really what I wanted.

The AppDaemon script allows for blocking the sun with centimeter accuracy, closing the shades as little as possible to not let the sun shine on my displays. When there is a sunny day I’m going to film a timelapse.

After seeing the integration, I thought about adding this approach to it (maybe as advanced mode with manual obstacles), but modeling the obstacle-planes is not really beginner-friendly and I can’t think of an easy solution for that.