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.
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
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
andscipy
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:
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!