Skip to content

Repository files navigation

Shade Engine — a half-drawn shade over a sunset window

Shade Engine

Validate Version Home Assistant HACS License

Note: This is heavily based on Adaptive Covers, and ultimately is a stripped down version that externalizes the state management so that I can control it with automations instead of influencing it with inputs. For most people you should probably try it first; it is more mature, has configuration built into the UI, and will certainly be better supported. If you need more control and you are ok with some manual config then you can give this one a try. On a side note, manual YAML config is quite a bit more friendly to agents so that is a side benefit.

Sun-tracking shade control for Home Assistant with externalized policy.

Shade Engine does two things and refuses to do a third:

  1. Calculates the highest shade position that keeps direct sun off your room, from window geometry (pure trigonometry, published as sensors).
  2. Actuates covers toward a target, with a movement deadband, rate limiting that defers rather than drops, and a visible hold whenever a human moves a cover.

It deliberately does not decide anything. All policy — when to track the sun, when to stay open for the view, when heat matters, schedules, seasons — lives in your own automations, which set a single select entity per zone. If you can read an automation trace, you can understand this system.

Why

Integrations that internalize the decision tree (climate strategies, weather vetoes, presence branches, hidden toggles) become impossible to reason about: configuration ends up split across a config flow, runtime switch entities, and behavior you only discover in the source. Shade Engine compartmentalizes instead:

Layer Where it lives What it is
Calculate this integration pure geometry → sensor.<zone>_glare_position
Decide your automations write select.<zone>_shade_mode, nothing else
Actuate this integration mode → target → cover.set_cover_position

Install

  1. HACS → Integrations → ⋮ → Custom repositories → add this repo as category Integration, then install.
  2. Add configuration to configuration.yaml (below) and restart.

Configuration

shade_engine:
  zones:
    kitchen:
      name: Kitchen
      covers:
        - cover.kitchen_shades
      window:
        azimuth: 268          # compass direction the window faces
        fov_left: 90          # degrees of sky left of the normal
        fov_right: 90         # degrees right
        height: 0.74          # meters of glass the shade covers
        protect_depth: 1.7    # meters into the room to keep sun off
        min_elevation: 0      # ignore sun below this elevation
      modes:
        night: 37             # constant position
        open: 100
        track: glare          # follow the calculator
        shield:               # calculator, clamped: never above 50
          max: 50
      default_mode: open
      motion:
        deadband: 3           # % change worth moving for
        min_interval: 300     # seconds between commands (deferred, not dropped)
        hold_duration: 3600   # seconds to stand down after a manual move
        settle: 90            # seconds to ignore reports after our own command

A zone is a set of covers that share geometry and move together. Mode names are yours; each maps to either a constant position (0–100), the string glare (pure calculator passthrough), or a mapping with min/max clamps applied to the calculator value.

Eye zone & reflected glare

protect_depth protects a strip of floor. If your actual problem is eyes — including sun that bounces off a shiny floor or countertop and up into them — replace it with an eye_zone and optional reflectors:

      window:
        azimuth: 268
        height: 0.74
        sill_height: 0.9      # meters from floor to the bottom of the glass
        eye_zone:
          height: [0.8, 1.4]  # meters above the floor to keep sun out of
          depth: [2.0, 4.0]   # meters from the window where eyes live
        reflectors:
          - height: 0.0       # the floor
          - height: 0.75      # a countertop...
            from: 0.0         # ...spanning this range of distance
            to: 0.6           #    from the window (omit "to" for unbounded)

All geometry is solved in the vertical plane along the sun's azimuth. Direct glare is excluded when the steepest admitted ray passes below the eye zone before reaching it. Each reflector adds one more constraint by mirror symmetry: a bounce off a surface at height r into the zone is a straight ray into the zone's reflection below that surface. The published position is the highest one satisfying every constraint — which is naturally non-monotonic over a day: high sun can force the shade down (floor bounce climbs into eyes), mid-descent can open up (bounces fall short of the zone), low sun closes again (direct rays at eye height).

Notes:

  • protect_depth is exactly eye_zone: {height: [0, x], depth: [d, inf]} — existing configs behave identically. Provide one of the two.
  • Reflectors assume worst-case specular (mirror) bounce and full window width. That over-shades rather than under-shades; if a reflector closes the shade at hours nobody experiences glare, narrow its from/to span or remove it.
  • reflectors require an eye_zone, and each reflector must sit below the zone's lower height.

Entities (per zone)

Each zone appears as a device under Settings → Devices & Services → Shade Engine, grouping all of its entities in one place. (The config entry behind that page is created automatically from the YAML — configuration is still YAML-only, and the UI "add integration" flow is intentionally disabled.)

Entity Meaning
select.<zone>_shade_mode current mode — the only thing policy writes
sensor.<zone>_glare_position calculator output; attrs: gamma, profile_angle, sun_in_window, constraint (direct / reflected / none — what bound the position)
sensor.<zone>_shade_target what the actuator wants; attrs: zone_id, covers, enabled, mode, last_decision (command / in_sync / rate_limited / hold_active / disabled), hold_until, last_command
binary_sensor.<zone>_sun_in_window direct sun geometrically possible now
binary_sensor.<zone>_shade_hold a human moved a cover; engine is standing down; attr hold_until
switch.<zone>_shade_control master on/off for the zone — off means the engine never commands these covers (no expiry, survives restarts); turning it back on reconciles immediately

Services

Service Purpose
shade_engine.hold start/refresh a hold (zone, optional duration seconds)
shade_engine.release clear a hold and reconcile — use in automations that must win over a manual move (e.g. privacy close at dusk)
shade_engine.reconcile evaluate immediately, bypassing rate limit (zone optional)
shade_engine.reload re-read the shade_engine: YAML and apply it without restarting HA. Each zone's mode, control switch and any active hold carry over; invalid YAML is rejected and the running config kept

Dashboard card

The integration bundles a Lovelace card and registers it as a frontend resource automatically — no HACS frontend install, no manual resource entry. Add it to any dashboard:

type: custom:shade-engine-card
entity: sensor.kitchen_shade_target

or, equivalently, by zone id from your YAML config:

type: custom:shade-engine-card
zone: kitchen

One card per zone shows:

  • Target / current / glare positions side by side (current reads the covers live; multiple covers show as 42 / 40).
  • Mode chips — every configured mode, tap to switch (writes the same select your automations do).
  • Sun-in-window indicator in the header.
  • Manual hold banner with a live countdown to hold_until and a Release button (shade_engine.release); when no hold is active, a Hold button pauses the zone for its configured hold_duration.
  • Control toggle — the zone's switch.<zone>_shade_control; off greys the card and the engine stands down entirely.
  • History strip — the shade target (solid) and each cover's actual position (dashed, from recorder history) as step lines over the last graph_hours (default 24), drawn over the sun's elevation curve (above-horizon only, computed locally from your home coordinates — not recorder data). Disable with graph: false.
  • A status badge explaining the last decision (In sync, Rate limited — retrying, Manual hold, Control off, Moving).

All sibling entities are derived from the target sensor's object-id prefix. If you've renamed entities, point the card at them explicitly with mode_entity, hold_entity, sun_entity, glare_entity, and switch_entity; title overrides the header; graph: false / graph_hours: 48 tune the history strip.

The card appears in the dashboard card picker as Shade Engine Card (after one browser refresh following installation or upgrade).

Behavior guarantees

  • Deferred, never dropped. A move suppressed by the rate limit or deadband is retried on the next evaluation (every 60 s, and on every sun or mode change). The system always converges to the current target.
  • Humans win. A cover position that doesn't match the last command (outside the settle window) starts a per-zone hold. The hold is a visible binary_sensor with a hold_until timestamp; shade_engine.release clears it. A forced evaluation (mode change, reconcile service) bypasses rate limiting but never bypasses a hold.
  • Off means off. switch.<zone>_shade_control is a hard gate: while it is off the engine never commands the zone's covers, manual moves are adopted silently (no hold), and nothing — not even a forced reconcile — overrides it. It restores across restarts.
  • Every non-move is explained. sensor.<zone>_shade_target always says why the engine last declined to act.

Example policy: a daylight state machine

# Bright and sunny: follow the sun.
- alias: "Shades: track when bright"
  triggers:
    - trigger: numeric_state
      entity_id: sensor.outdoor_illuminance
      above: 10000
      for: "00:05:00"
  actions:
    - action: select.select_option
      target: { entity_id: select.kitchen_shade_mode }
      data: { option: track }

# Dim: open up for the view.
- alias: "Shades: open when dim"
  triggers:
    - trigger: numeric_state
      entity_id: sensor.outdoor_illuminance
      below: 10000
      for: "00:05:00"
  actions:
    - action: select.select_option
      target: { entity_id: select.kitchen_shade_mode }
      data: { option: open }

# Hot room: shield even when glare geometry alone would open up.
- alias: "Shades: shield when hot"
  triggers:
    - trigger: numeric_state
      entity_id: sensor.kitchen_temperature
      above: 25
  conditions:
    - condition: numeric_state
      entity_id: sensor.outdoor_illuminance
      above: 10000
  actions:
    - action: select.select_option
      target: { entity_id: select.kitchen_shade_mode }
      data: { option: shield }

# Dark: privacy positions. release first so night always wins.
- alias: "Shades: night close"
  triggers:
    - trigger: numeric_state
      entity_id: sensor.outdoor_illuminance
      below: 50
      for: "00:05:00"
  actions:
    - action: shade_engine.release
      data: { zone: kitchen }
    - action: select.select_option
      target: { entity_id: select.kitchen_shade_mode }
      data: { option: night }

Simulator

docs/simulator.html is a self-contained, single-page simulator (no build, no network — open it in any browser). Paste your zone YAML, pick a date, location, and sky, and it plays the whole stack through a day: the calculator's glare position (a direct port of calculator.py, eye-zone and reflected-glare constraints included), a lux/elevation policy ladder with editable thresholds, and the actuator's real semantics — deadband, rate limiting that defers, and manual-move holds (there's a control to inject a manual move and watch the hold start and expire). A room cross-section diagram shows the sun ray, eye zone, and reflector bounce at any scrubbed minute — useful for sanity-checking a zone's geometry before deploying it.

Verifying your geometry

Run in shadow mode first: configure zones, restart, and graph sensor.<zone>_glare_position against the sun for a couple of sunny days before pointing covers at anything real (or set every mode to a constant while you watch). Tune protect_depth until the curve drops when glare actually reaches the spot you care about. With an eye_zone, watch the constraint attribute too: reflected at hours when nothing actually bounces into your eyes means a reflector span is too generous.

Development

The calculator and decision core are pure Python with no Home Assistant imports:

python3 -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python -m pytest tests/ -v

Roadmap

  • Optional tilt support for venetian-style slats
  • Hold persistence across restarts

About

A simple engine for shade automation.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages