Created
March 12, 2026 11:47
-
-
Save FezVrasta/88bc87b3edd5288a1504d94f2c06f0dd to your computer and use it in GitHub Desktop.
Sun Tracking Cover Home Assistant Blueprint
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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