Skip to content

Instantly share code, notes, and snippets.

@FezVrasta
Created March 12, 2026 11:47
Show Gist options
  • Select an option

  • Save FezVrasta/88bc87b3edd5288a1504d94f2c06f0dd to your computer and use it in GitHub Desktop.

Select an option

Save FezVrasta/88bc87b3edd5288a1504d94f2c06f0dd to your computer and use it in GitHub Desktop.
Sun Tracking Cover Home Assistant Blueprint
blueprint:
name: Sun Tracking Cover
description: >-
Automatically adjusts cover/blind positions based on sun position to prevent
direct sunlight from reaching a target area (desk, couch, artwork, plants, etc.).
**How it works:** The automation calculates the optimal cover position using
window geometry and sun elevation to block direct sunlight while maximizing
natural light.
**Key measurements (all from floor level):**
- Window Top: Distance from floor to the TOP edge of your window
- Window Sill: Distance from floor to the BOTTOM edge of your window
- Target Distance: How far the protected area is from the window
- Target Top: Height of the TOP of what you're protecting (e.g., top of monitor)
domain: automation
author: ASA
input:
covers:
name: Covers
description: One or more cover entities to control
selector:
entity:
domain: cover
multiple: true
window_azimuth:
name: Window Azimuth
description: >-
Direction the window faces in degrees (0=North, 90=East, 180=South, 270=West).
Tip: Stand inside facing the window and use a compass app to measure.
selector:
number:
min: 0
max: 359
unit_of_measurement: "°"
mode: slider
window_height:
name: Window Height
description: >-
Height from the FLOOR to the TOP of the window (not the window size).
Measure from floor level to the top edge of the glass.
Example: A window with its top at 2.3m from the floor = 2.3
selector:
number:
min: 0.5
max: 5
step: 0.05
unit_of_measurement: "m"
mode: slider
target_distance:
name: Target Distance
description: >-
Horizontal distance from the window to what you're protecting.
Measure from the window glass to the front edge of your desk/couch/artwork.
selector:
number:
min: 0.5
max: 10
step: 0.1
unit_of_measurement: "m"
mode: slider
window_sill:
name: Window Sill Height
description: >-
Height from the FLOOR to the BOTTOM of the window glass (the sill).
When fully closed (position=0%), the cover bottom rests here.
Example: if the sill is 0.9m from the floor = 0.9
default: 0.9
selector:
number:
min: 0
max: 3
step: 0.05
unit_of_measurement: "m"
mode: slider
target_top:
name: Target Top Height
description: >-
Height from the FLOOR to the TOP of what you're protecting.
Used to determine when the sun is high enough that the cover can be fully open.
Examples: 1.3 for top of a monitor, 0.9 for top of a couch backrest.
default: 1.3
selector:
number:
min: 0
max: 3
step: 0.05
unit_of_measurement: "m"
mode: slider
sun_angle_range:
name: Sun Angle Range
description: >-
Only adjust covers when sun direction is within ±X° of the window azimuth.
Use 45° for standard windows, increase for wider windows or corner rooms.
default: 45
selector:
number:
min: 10
max: 90
unit_of_measurement: "°"
mode: slider
min_position:
name: Minimum Position
description: >-
The most closed the cover should go (0%=fully closed, 100%=fully open).
Set above 0% to avoid complete blackout and keep some ambient light.
default: 23
selector:
number:
min: 0
max: 100
unit_of_measurement: "%"
mode: slider
max_position:
name: Maximum Position
description: >-
The most open the cover should go when sun isn't a problem.
Set below 100% if you never want the cover fully retracted.
default: 100
selector:
number:
min: 0
max: 100
unit_of_measurement: "%"
mode: slider
min_elevation:
name: Minimum Sun Elevation
description: >-
When sun is below this angle, covers stay fully open.
Low sun (sunrise/sunset) is usually blocked by buildings or trees.
default: 5
selector:
number:
min: 0
max: 30
unit_of_measurement: "°"
mode: slider
update_interval:
name: Update Interval
description: >-
How often to recalculate the cover position.
Lower values = smoother tracking but more cover movements.
default: 10
selector:
number:
min: 1
max: 60
unit_of_measurement: "min"
mode: slider
position_threshold:
name: Position Threshold
description: >-
Only move the cover if the calculated change exceeds this value.
Prevents constant small adjustments that wear out the motor.
default: 5
selector:
number:
min: 1
max: 20
unit_of_measurement: "%"
mode: slider
enable_boolean:
name: Enable Boolean (Optional)
description: >-
Optional input_boolean to manually enable/disable sun tracking.
Leave empty to always run when conditions are met.
default: []
selector:
entity:
domain: input_boolean
multiple: false
weather_entity:
name: Weather Entity (Optional)
description: >-
Weather integration to check cloud coverage.
When cloudy, covers open fully since direct sunlight isn't an issue.
default: []
selector:
entity:
domain: weather
multiple: false
max_cloud_coverage:
name: Maximum Cloud Coverage
description: >-
Cloud coverage threshold above which covers open fully.
Set higher (e.g., 90%) if you only want to open on very cloudy days.
default: 70
selector:
number:
min: 0
max: 100
unit_of_measurement: "%"
mode: slider
reflection_margin:
name: Maximum Reflection Margin
description: >-
Maximum extra closing percentage to account for reflected/indirect light.
Applied dynamically: scales from 0 at min elevation to full margin at the critical
elevation, reduced proportionally by cloud coverage.
Increase for bright rooms with light walls/floors, keep low for darker rooms.
default: 0
selector:
number:
min: 0
max: 30
unit_of_measurement: "%"
mode: slider
window_sensor:
name: Window Sensor (Optional)
description: >-
Binary sensor that detects if the window is open.
When open, the cover won't move for safety reasons.
default: []
selector:
entity:
domain: binary_sensor
device_class: window
multiple: false
variables:
covers_input: !input covers
window_azimuth_input: !input window_azimuth
window_height_input: !input window_height
window_sill_input: !input window_sill
target_distance_input: !input target_distance
target_top_input: !input target_top
sun_angle_range_input: !input sun_angle_range
min_position_input: !input min_position
max_position_input: !input max_position
min_elevation_input: !input min_elevation
position_threshold_input: !input position_threshold
enable_boolean_input: !input enable_boolean
weather_entity_input: !input weather_entity
max_cloud_coverage_input: !input max_cloud_coverage
reflection_margin_input: !input reflection_margin
window_sensor_input: !input window_sensor
# Calculate the target position (self-contained to avoid variable reference issues)
calculated_position: >-
{% set sun_az = state_attr('sun.sun', 'azimuth') | float(0) %}
{% set sun_el = state_attr('sun.sun', 'elevation') | float(0) %}
{% set angle_diff = (((sun_az - window_azimuth_input + 180) % 360) - 180) | abs %}
{% set in_danger = angle_diff <= sun_angle_range_input %}
{% set cover_travel = window_height_input - window_sill_input %}
{# Critical elevation: ray through window top clears target_top → cover can be fully open #}
{% set crit_el = atan((window_height_input - target_top_input) / target_distance_input) * (180 / pi) %}
{% set cloud_pct = state_attr(weather_entity_input, 'cloud_coverage') | float(0) if weather_entity_input else 0 %}
{% set too_cloudy = weather_entity_input and cloud_pct > max_cloud_coverage_input %}
{% if not in_danger or sun_el < min_elevation_input or too_cloudy %}
{{ max_position_input }}
{% elif sun_el >= crit_el %}
{{ max_position_input }}
{% else %}
{# Ray grazing cover bottom must clear target_top: cover_bottom - target_distance*tan(el) >= target_top #}
{# Minimum required cover_bottom = target_top + target_distance*tan(el) #}
{% set sun_drop = target_distance_input * tan(sun_el * pi / 180) %}
{% set required_cover_bottom = target_top_input + sun_drop %}
{# Dynamic reflection margin: normalized to crit_el so full margin is reached at max sun angle #}
{% set irradiance = (sun_el / crit_el) * (1 - cloud_pct / 100) %}
{% set dynamic_margin = irradiance * reflection_margin_input %}
{# Convert cover bottom height to position %: position = (cover_bottom - window_sill) / cover_travel * 100 #}
{% set raw_position = (required_cover_bottom - window_sill_input) / cover_travel * 100 - dynamic_margin %}
{{ [[raw_position | round(0), min_position_input] | max, max_position_input] | min }}
{% endif %}
trigger_variables:
update_interval_var: !input update_interval
trigger:
- platform: template
value_template: "{{ now().minute % update_interval_var == 0 and now().second < 30 }}"
id: interval
- platform: homeassistant
event: start
id: ha_start
- platform: sun
event: sunrise
offset: "00:30:00"
id: sunrise
- platform: sun
event: sunset
offset: "-00:30:00"
id: sunset
condition:
# Enable boolean check (if configured)
- condition: template
value_template: >-
{% if enable_boolean_input %}
{{ is_state(enable_boolean_input, 'on') }}
{% else %}
true
{% endif %}
# Window must be closed (if sensor configured)
- condition: template
value_template: >-
{% if window_sensor_input %}
{{ is_state(window_sensor_input, 'off') }}
{% else %}
true
{% endif %}
# Daytime only (sun above horizon)
- condition: numeric_state
entity_id: sun.sun
attribute: elevation
above: 0
action:
- variables:
target_position: "{{ calculated_position | int }}"
# Process each cover
- repeat:
for_each: "{{ covers_input if covers_input is list else [covers_input] }}"
sequence:
- variables:
current_cover: "{{ repeat.item }}"
current_position: "{{ state_attr(current_cover, 'current_position') | int(0) }}"
position_change: "{{ (target_position - current_position) | abs }}"
# Only adjust if cover is available and change exceeds threshold
- condition: template
value_template: >-
{{ states(current_cover) not in ['unavailable', 'unknown']
and position_change >= position_threshold_input }}
- service: cover.set_cover_position
target:
entity_id: "{{ current_cover }}"
data:
position: "{{ target_position }}"
mode: single
max_exceeded: silent
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment