blueprint: name: Linked Entities description: >- [![Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fraw.githubusercontent.com%2Falexdelprete%2Fha-blueprints%2Fmain%2Fha-blueprint-linked-entities.yaml) **Linked Entities v2.2.0** 🔛 This blueprint allows you to easily create/maintain an automation that links the state of multiple entities: - turn ANY linked entity ON, it will turn ON ALL linked entities. - turn ANY linked entity OFF, it will turn OFF ALL linked entities. - set the brightness of any light entity, it will set the same brightness on ALL linked light entities. - set the color of any light entity (color_temp_kelvin / hs / rgb / xy), it will mirror the color on ALL linked light entities. - set the speed (percentage) of any fan entity, it will set the speed on ALL linked fan entities. - when an entity comes back from `unavailable`/`unknown`, it adopts the state (and brightness/color/speed) of the linked entities that stayed online. **NOTE**: The entity selector accepts `light`, `switch`, `fan`, `input_boolean`, `siren`, `humidifier` and `remote`. Mixing domains in one instance is supported: on/off propagates to every linked entity, brightness/color only to lights, speed only to fans. If your network/devices generate spurious state echoes (Matter/Tapo dimmers, slow Zigbee mesh, etc.) increase the `Debounce (on/off)` input to suppress them. My main use-case was for multiple light switches in the house controlling the same light, but I also use it for other things: - at dawn, when I turn on the external lights (a shelly switch), I also link the pool light mqtt switch. - when I want to open the external gate, I can use several linked switches in multiple rooms of the house - when I want to manually turn on/off the irrigation system, I use two switches (internal and external) to activate my RainMachine cycle I'm sure you'll find many more use-cases. :slight_smile: **CHANGELOG:** - 2.2.0: (2026-10-13) - **Fix ([#11](https://github.com/alexdelprete/ha-blueprints/issues/11))**: the six per-attribute triggers (brightness, color, fan speed) are replaced by a single plain state trigger on the linked entities. A blueprint cannot restrict an input per domain, so the old triggers watched e.g. `percentage` on lights and `brightness` on switches, which Spook's "unknown attributes" check reports as ghosts. Which attributes changed is now worked out inside the run. Thanks @junk67 for the report. - **Fix**: a brightness and color change arriving in one update (scenes, adaptive lighting, RGBW controllers) now propagates both. Previously the brightness trigger won the `mode: single` slot and the color change was dropped. Also reported, with a proposed fix, by @butsify in [PR #10](https://github.com/alexdelprete/ha-blueprints/pull/10). - **Change**: during a slider drag the linked lights now follow in samples (every attribute debounce + delay + device round trip) and are guaranteed to end on the final value. The previous design propagated only the final value and could miss it when it landed inside another run's trailing delay. - **Improvement**: attribute sync also turns on linked entities that are still off, so a turn-on dropped by `mode: single` can no longer leave a group split. - 2.1.0: (2026-10-06) - **New**: supported domains extended with `siren`, `humidifier` and `remote` (on/off sync). - **Change**: recovery from `unavailable`/`unknown` no longer pushes the recovering entity's state to the group. The recovering entity now *adopts* the state of the linked entities that stayed online, and only when they all agree; lights and fans also adopt brightness/color/speed from a same-domain peer. Fixes state flips after HA restarts or integration reloads, when entities came back in arbitrary order and the first one to appear overwrote the others. - **Fix**: self-trigger guard hardened. State changes that share the originating context of the automation's last run are now rejected too. Previously, the attribute triggers that co-fire with a light turn-on were dropped by `mode: single` but still overwrote the stored context, so echoes arriving after the trailing delay slipped through. - **Fix**: fan speed is now carried when a fan turns on (same class of bug as [#3](https://github.com/alexdelprete/ha-blueprints/issues/3) for lights). Linked fans no longer come up at their previous speed. - **Fix**: linked lights and fans receive a single service call on turn-on, carrying brightness/color or speed directly. Previously a plain `turn_on` was followed by a second call with the attributes, causing a brief flash at the previous brightness/color. - **Change**: the debounce is split in two. `Debounce (on/off)` (existing input `debounce_milliseconds`, so values you already tuned keep applying) now covers only on/off changes and its default drops from 100 to 50 ms, which is the only fixed cost in the reaction time of linked entities. The new `Debounce (brightness/color/speed)` (`attribute_debounce_milliseconds`, default 100 ms) covers attribute changes, where it smooths slider drags. - **Change**: recovery waits a fixed 1 s settle before adopting the group state, instead of the on/off debounce, so the recovering device can finish its restore sequence and its peers have time to come online too. - **Improvement**: declared `min_version` 2025.4.0 (the `combine` template filter) and switched to the modern `triggers:` / `conditions:` / `actions:` syntax. - **Docs**: the `Delay` description no longer references `queued` mode. - **Improvement**: 20 stored traces per automation (HA default is 5), so the real run's trace is no longer evicted by the dropped co-firing triggers and rejected echoes of the same toggle. - 2.0.2: (2026-05-13) — color_temp regression fix - **Fix**: linked lights no longer revert to a dim blueish tint when the source is set to a white color temperature. v2.0's priority chain (`hs > rgb > xy > color_temp_kelvin`) always picked `hs_color`, but Home Assistant *derives* `hs_color`/`rgb_color`/`xy_color` from kelvin when a light is in `color_mode: color_temp` — and that derived hs sits in a low-saturation blue range. Linked bulbs then ran their RGB LEDs at that hue instead of their dedicated white LEDs, producing the dim-blue look. The color attribute to propagate is now chosen from the source's `color_mode`, with the old priority chain kept only as a fallback if `color_mode` is missing. Thanks @mcmasterp for the report. - 2.0.1: (2026-05-11) — regression fix - **Fix**: feedback loop on auto-off / pulse switches (Shelly gate openers, momentary relays). v2.0's `mode: queued` exposed a latent flaw in the context-id self-trigger guard — echoes from the blueprint's own service calls slipped through because each queued run had its own context. Reverted to `mode: single`, which keeps the trailing delay active long enough for echoes to be silently dropped. v2.0's other fixes (the `turn_on` branch now carries brightness/color directly) make the loss of queued mode harmless for lights. - **Fix**: tightened the self-trigger guard to also reject state events whose `context.parent_id` matches the automation's last context — defense-in-depth in case `mode: single` ever lets a one-hop echo through. - 2.0: (2026-05-11) - **Fix ([#6](https://github.com/alexdelprete/ha-blueprints/issues/6))**: linked entities now sync correctly when an entity recovers from `unavailable`/`unknown` to `on` or `off` — covers device-restart and integration-reload cases. Thanks @nsitt for the report. - **Fix ([#3](https://github.com/alexdelprete/ha-blueprints/issues/3))**: brightness and color are now correctly propagated when a linked light turns on. Previously the `turn_on` branch dropped attribute triggers due to `mode: single` racing with state-change fan-out, so linked lights came up at their previous brightness/color. Thanks @jrosspaperless for the report and @jnrcorp / @richard-berg for the diagnosis and prototypes. - **Fix**: full color support — `hs_color`, `rgb_color`, `xy_color` are now propagated alongside `color_temp_kelvin` (priority: hs > rgb > xy > color_temp_kelvin), so RGB/Hue/Lifx-style bulbs sync color, not just whites. Inspired by @richard-berg's fork. - **Fix**: color flicker on multi-attribute color changes is eliminated — when a light updates several color attributes at once (e.g. hs + rgb + xy together), all color triggers route through a single branch with priority-resolved color, so linked lights receive identical updates instead of competing partial writes. - **Fix**: echo loop mitigation for slow/flaky networks — added a configurable per-trigger debounce. Default 100 ms swallows most spurious state echoes (e.g., Matter/Tapo dimmers reporting a momentary off after on). Users can raise it up to 5000 ms if needed. Thanks @AbbieDoobie for the report. - **Fix**: domain-aware service calls — `light.turn_on` and `fan.set_percentage` only run against entities of the matching domain. Mixed-domain instances no longer error. - **Fix**: switched from deprecated `color_temp` (mireds) to `color_temp_kelvin` (HA 2024.3+). - **Fix**: fan speed branch now ignores non-fan sources (was firing on any entity with a `percentage` attribute). - **Breaking**: input renamed `delay_miliseconds` → `delay_milliseconds`. Existing instances will revert to the default (200 ms) until re-saved. - **Breaking**: entity selector restricted to `light`, `switch`, `fan`, `input_boolean`. Thanks @Aaroneisele55 for PR #9. Remove any other entity types from existing instances. - **Improvement**: trailing per-branch delay retained for throttling and echo absorption under `mode: single`. - **Improvement**: optional `transition_seconds` input for smooth brightness/color changes. - **Improvement**: modern `target:` service-call syntax. - 1.3: (2024-07-10 - thanks @jsenecal for PR #2) - Ignore changes to entities triggered by this automation - Do not change the state of the entity triggering the automation - 1.2: (2024-01-28 - thanks @phrak / @TimU for PR #1) - Changed the action to a "Choose" building block to support fan, light and future attributes - Added support for linked fan speeds - Added support for linked light brightness and light color temperature - 1.1: (2024-01-09) - Optimized service call leveraging trigger.id - Introduced max_exceeded to avoid warnings in log due to possible self-triggering - Introduced a small delay after the service call to throttle the automation - 1.0: (2023-12-29) - First official release **If you like to show your support please hit click here:** [![contributions - welcome](https://img.shields.io/badge/contributions-welcome-blue)](https://www.paypal.com/donate/?hosted_button_id=8V9YE6S5E869G "PayPal Donation") **Source:** [![alexdelprete - ha-blueprints](https://img.shields.io/static/v1?label=alexdelprete&message=ha-blueprints&color=blue&logo=github)](https://github.com/alexdelprete/ha-blueprints/blob/main/ha-blueprint-linked-entities.yaml "Go to GitHub repo") [![stars - ha-blueprints](https://img.shields.io/github/stars/alexdelprete/ha-blueprints?style=social)](https://github.com/alexdelprete/ha-blueprints) [![forks - ha-blueprints](https://img.shields.io/github/forks/alexdelprete/ha-blueprints?style=social)](https://github.com/alexdelprete/ha-blueprints) **Support:** [![HA Community - Topic](https://img.shields.io/static/v1?label=HA+Community&message=Topic&color=2ea44f&logo=home-assistant)](https://community.home-assistant.io/t/linked-entities-keep-mutlple-entities-binary-state-in-sync-lights-switches-etc/662836?u=alexdelprete) domain: automation author: Alessandro Del Prete homeassistant: min_version: "2025.4.0" input: linked_entity: name: Linked Entities description: Entities whose state and attributes will be linked. On/off propagates to all of them; brightness/color only to lights; speed only to fans. selector: entity: filter: domain: - light - switch - fan - input_boolean - siren - humidifier - remote multiple: true delay_milliseconds: name: Delay description: Pause after each propagated service call (ms). Throttles back-to-back calls and absorbs state echoes from the blueprint's own actions. selector: number: min: 0 max: 1000 unit_of_measurement: milliseconds mode: box default: 200 debounce_milliseconds: name: Debounce (on/off) description: How long an on/off change must remain stable before propagating (ms). This is the only fixed cost in the reaction time of linked entities. Default 50 ms absorbs the split/duplicate reports some devices send while staying imperceptible; set to 0 on fast local networks (ESPHome, Tasmota, Shelly on good WiFi), raise to 500–2000 ms if a device reports spurious off-after-on glitches (Matter/Tapo dimmers, slow Zigbee mesh). selector: number: min: 0 max: 5000 unit_of_measurement: milliseconds mode: box default: 50 attribute_debounce_milliseconds: name: Debounce (brightness/color/speed) description: Wait before sampling and propagating a brightness, color or fan-speed change (ms). During a slider drag the linked lights follow in samples spaced by this value plus Delay plus the device round trip, and always end on the final value. Default 100 ms; raise to 200–300 ms for fewer intermediate steps, lower to 0 for the fastest reaction. selector: number: min: 0 max: 5000 unit_of_measurement: milliseconds mode: box default: 100 transition_seconds: name: Light Transition description: Optional transition time (seconds) for brightness/color changes on linked lights. Set to 0 to disable. selector: number: min: 0 max: 10 step: 0.5 unit_of_measurement: seconds mode: box default: 0 mode: single max_exceeded: silent # Every toggle produces several trace entries (the real run, the attribute # triggers dropped by `mode: single`, the rejected echoes). HA's default of 5 # stored traces evicts the real run within a second, so keep more. trace: stored_traces: 20 variables: linked_entities: !input linked_entity transition_seconds: !input transition_seconds triggers: - trigger: state id: turn_on entity_id: !input linked_entity from: "off" to: "on" for: milliseconds: !input debounce_milliseconds - trigger: state id: turn_off entity_id: !input linked_entity from: "on" to: "off" for: milliseconds: !input debounce_milliseconds # Recovery is not latency-sensitive. A fixed 1 s settle lets the recovering # device finish its restore sequence and gives its peers time to come online # too, so the unanimity check below sees as much of the group as possible. - trigger: state id: recovered entity_id: !input linked_entity from: - "unavailable" - "unknown" to: - "on" - "off" for: seconds: 1 # One trigger for every attribute change (brightness, color, fan speed). # Deliberately no `attribute:` filter: a blueprint cannot restrict an input # per domain, and watching e.g. `percentage` on a light makes Spook report a # ghost attribute (#11). Which attributes changed is worked out in the run. # `for:` here only delays the first fire (the timers of a bare state trigger # are not cancelled by further attribute changes); the convergence loop in # the attribute branch is what makes a slider drag end on the final value. - trigger: state id: attribute_changed entity_id: !input linked_entity for: milliseconds: !input attribute_debounce_milliseconds conditions: # Self-trigger guard. Reject state changes caused by this automation's own # service calls: # 1. same context id as the last run, # 2. a child context of the last run, # 3. same originating (parent) context as the last run. Needed because # triggers dropped by `mode: single` (e.g. brightness/color attribute # triggers co-firing with a light turn-on) still overwrite the stored # context, so check 1 alone would let late echoes through. - condition: template value_template: >- {{ trigger.to_state.context.id != this.context.id and trigger.to_state.context.parent_id != this.context.id and (trigger.to_state.context.parent_id is none or trigger.to_state.context.parent_id != this.context.parent_id) }} actions: - variables: src_domain: "{{ trigger.entity_id.split('.')[0] }}" others: "{{ linked_entities | reject('eq', trigger.entity_id) | list }}" other_lights: "{{ others | select('match', '^light\\.') | list }}" other_fans: "{{ others | select('match', '^fan\\.') | list }}" # On/off states of the peers that are currently online, deduplicated. # A single element means every online peer agrees. peer_states: "{{ others | map('states') | select('in', ['on', 'off']) | unique | list }}" # Entity whose attributes are propagated. Normally the trigger entity; # on recovery, the first same-domain peer that is on (or none). src_entity: >- {%- if trigger.id == 'recovered' -%} {%- set ns = namespace(peer=none) -%} {%- for e in others if ns.peer is none and e.split('.')[0] == src_domain and is_state(e, 'on') -%} {%- set ns.peer = e -%} {%- endfor -%} {{ ns.peer }} {%- else -%} {{ trigger.entity_id }} {%- endif -%} state_changed: "{{ trigger.from_state is none or trigger.from_state.state != trigger.to_state.state }}" relevant_attrs: >- {{ ['brightness', 'color_mode', 'color_temp_kelvin', 'hs_color', 'rgb_color', 'xy_color'] if src_domain == 'light' else (['percentage'] if src_domain == 'fan' else []) }} # Attributes that differ between from_state and to_state (attribute_changed only). changed_attrs: >- {%- set ns = namespace(c=[]) -%} {%- if trigger.id == 'attribute_changed' and trigger.from_state is not none -%} {%- for k in relevant_attrs if trigger.from_state.attributes.get(k) != trigger.to_state.attributes.get(k) -%} {%- set ns.c = ns.c + [k] -%} {%- endfor -%} {%- endif -%} {{ ns.c }} # Everything derived from the source entity's current attributes. Anchored so # the attribute branch can re-evaluate it on every loop iteration. - variables: &source_attributes src_brightness: "{{ state_attr(src_entity, 'brightness') if src_entity is not none else none }}" src_color_mode: "{{ state_attr(src_entity, 'color_mode') if src_entity is not none else none }}" src_color_temp_kelvin: "{{ state_attr(src_entity, 'color_temp_kelvin') if src_entity is not none else none }}" src_hs_color: "{{ state_attr(src_entity, 'hs_color') if src_entity is not none else none }}" src_rgb_color: "{{ state_attr(src_entity, 'rgb_color') if src_entity is not none else none }}" src_xy_color: "{{ state_attr(src_entity, 'xy_color') if src_entity is not none else none }}" src_percentage: "{{ state_attr(src_entity, 'percentage') if src_entity is not none else none }}" color_data: >- {% if src_color_mode == 'hs' and src_hs_color is not none %}{{ {'hs_color': src_hs_color} }} {%- elif src_color_mode in ('rgb', 'rgbw', 'rgbww') and src_rgb_color is not none %}{{ {'rgb_color': src_rgb_color} }} {%- elif src_color_mode == 'xy' and src_xy_color is not none %}{{ {'xy_color': src_xy_color} }} {%- elif src_color_mode == 'color_temp' and src_color_temp_kelvin is not none %}{{ {'color_temp_kelvin': src_color_temp_kelvin} }} {%- elif src_color_mode in ('white', 'brightness', 'onoff') %}{{ {} }} {%- elif src_hs_color is not none %}{{ {'hs_color': src_hs_color} }} {%- elif src_rgb_color is not none %}{{ {'rgb_color': src_rgb_color} }} {%- elif src_xy_color is not none %}{{ {'xy_color': src_xy_color} }} {%- elif src_color_temp_kelvin is not none %}{{ {'color_temp_kelvin': src_color_temp_kelvin} }} {%- else %}{{ {} }}{%- endif %} transition_data: "{{ {'transition': transition_seconds} if transition_seconds > 0 else {} }}" light_on_data: >- {{ ({'brightness': src_brightness} if src_brightness is not none else {}) | combine(color_data) | combine(transition_data) }} # True when linked lights/fans must be turned on with attributes in a # single domain-specific call instead of the plain homeassistant.turn_on. carry_light: "{{ src_domain == 'light' and light_on_data | count > 0 }}" carry_fan: "{{ src_domain == 'fan' and src_percentage is not none and src_percentage | int(0) > 0 }}" others_plain: >- {{ others | reject('in', other_lights if carry_light else []) | reject('in', other_fans if carry_fan else []) | list }} - choose: - conditions: - condition: trigger id: turn_on sequence: - if: - "{{ others_plain | count > 0 }}" then: - action: homeassistant.turn_on target: entity_id: "{{ others_plain }}" - if: - "{{ carry_light and other_lights | count > 0 }}" then: - action: light.turn_on target: entity_id: "{{ other_lights }}" data: "{{ light_on_data }}" - if: - "{{ carry_fan and other_fans | count > 0 }}" then: - action: fan.turn_on target: entity_id: "{{ other_fans }}" data: percentage: "{{ src_percentage }}" - delay: milliseconds: !input delay_milliseconds - conditions: - condition: trigger id: turn_off sequence: - if: - "{{ others | count > 0 }}" then: - action: homeassistant.turn_off target: entity_id: "{{ others }}" - delay: milliseconds: !input delay_milliseconds # Recovery: the entity that just came back online is the least # trustworthy member of the group, so it adopts the peers' state instead # of pushing its own. Acts only when every online peer agrees; the first # entity to recover after an HA restart finds no online peer and does # nothing. - conditions: - condition: trigger id: recovered - "{{ peer_states | count == 1 }}" sequence: - if: - "{{ peer_states[0] != trigger.to_state.state }}" then: - action: "homeassistant.turn_{{ peer_states[0] }}" target: entity_id: "{{ trigger.entity_id }}" - if: - "{{ peer_states[0] == 'on' and src_domain == 'light' and src_entity is not none and light_on_data | count > 0 }}" then: - action: light.turn_on target: entity_id: "{{ trigger.entity_id }}" data: "{{ light_on_data }}" - if: - "{{ peer_states[0] == 'on' and src_domain == 'fan' and src_entity is not none and src_percentage is not none }}" then: - action: fan.set_percentage target: entity_id: "{{ trigger.entity_id }}" data: percentage: "{{ src_percentage }}" - delay: milliseconds: !input delay_milliseconds # Attribute changes (brightness / color / fan speed). Samples the source, # sends to the same-domain peers, waits, and repeats while the source # keeps changing, so a slider drag ends on the final value even though # `mode: single` drops the triggers fired during the run. - conditions: - condition: trigger id: attribute_changed - "{{ not state_changed and trigger.to_state.state == 'on' and changed_attrs | count > 0 }}" - "{{ (src_domain == 'light' and other_lights | count > 0) or (src_domain == 'fan' and other_fans | count > 0) }}" sequence: - repeat: sequence: - variables: *source_attributes - variables: snapshot_updated: "{{ (expand(trigger.entity_id) | map(attribute='last_updated') | list | first | default(none)) | string }}" # Followers that are still off (e.g. their turn-on run was # dropped because this run already held the lock): turn them # on too, so attribute sync never leaves the group split. others_off_plain: "{{ others | reject('in', other_lights if src_domain == 'light' else other_fans) | select('is_state', 'off') | list }}" - if: - "{{ others_off_plain | count > 0 }}" then: - action: homeassistant.turn_on target: entity_id: "{{ others_off_plain }}" - if: - "{{ src_domain == 'light' and light_on_data | count > 0 }}" then: - action: light.turn_on target: entity_id: "{{ other_lights }}" data: "{{ light_on_data }}" - if: - "{{ src_domain == 'fan' and src_percentage is not none }}" then: - action: fan.set_percentage target: entity_id: "{{ other_fans }}" data: percentage: "{{ src_percentage }}" - delay: milliseconds: !input delay_milliseconds until: - "{{ repeat.index >= 20 or (expand(trigger.entity_id) | map(attribute='last_updated') | list | first | default(none)) | string == snapshot_updated }}"