# Presence Scene Controller Blueprint # Per-room presence-aware time-of-day scene controller. # See README.md for detailed documentation. # # Author: Alexei Dolgolyov (dolgolyov.alexei@gmail.com) blueprint: name: "Custom: Presence Scene Controller" description: > Per-room presence-aware time-of-day scene controller. Maps scenes to time-of-day options by index (like Day Scene Controller), but adds presence gating, vacant/sleep scenes, and Motion Light coexistence. Designed to take over the per-room responsibilities of Day Scene Controller — keep Day Scene Controller for genuinely house-wide scenes (Away, Goodnight, All-Off). domain: automation input: # ------------------------------------------------------------------------- # State Sources # ------------------------------------------------------------------------- states_group: name: States collapsed: false input: time_of_day_state: name: Time of Day State Selector description: > input_select entity holding the current time-of-day state. Typically managed by Time of Day Selector or Time of Day Controller. selector: entity: domain: input_select room_enable_switch: name: Room Enable Switch (optional) description: > When OFF, the vacant scene is applied and TOD/presence are ignored. Useful as a per-room kill switch (movie night, guest bedroom). Leave empty to skip this gate. default: "" selector: entity: domain: - binary_sensor - input_boolean - switch # ------------------------------------------------------------------------- # Presence # ------------------------------------------------------------------------- presence_group: name: Presence collapsed: false input: presence_sensors: name: Presence Sensors (optional) description: > Any presence-related entities — motion sensors, mmWave, occupancy groups, input_booleans. ANY-on logic: the room is occupied if any sensor reports on. Sensors reporting unavailable/unknown are treated as on (safe default). Leave empty to disable presence gating; the room is then always treated as occupied and the vacant scene is never applied. default: [] selector: entity: domain: - binary_sensor - input_boolean - group multiple: true presence_off_timeout: name: Presence Off Timeout (seconds) description: > How long all presence sensors must report off before the room is considered vacant. Filters PIR drop-outs and short absences. default: 120 selector: number: min: 0 max: 3600 step: 5 unit_of_measurement: seconds # ------------------------------------------------------------------------- # Scenes # ------------------------------------------------------------------------- scenes_group: name: Scenes collapsed: false input: scenes: name: Time-of-Day Scenes description: > Ordered list of scenes matching time-of-day options by index. Length must equal the number of time-of-day options. selector: entity: domain: scene multiple: true vacant_scene: name: Vacant Scene description: > Scene applied when the room becomes vacant (after the presence-off timeout) or when the room enable switch is OFF. Typically a dim or all-off scene. selector: entity: domain: scene sleep_scene: name: Sleep Scene (optional) description: > Scene applied when the sleep mode switch is ON. Bypasses TOD and presence entirely. Typically a very dim "do not disturb" scene or all-off. Leave empty if sleep mode is not used. default: "" selector: entity: domain: scene # ------------------------------------------------------------------------- # Behavior # ------------------------------------------------------------------------- behavior_group: name: Behavior collapsed: false input: tod_behavior_while_occupied: name: TOD Change Behavior While Occupied description: > What to do when time-of-day flips while the room is occupied: - apply_if_lights_off (default): re-apply only if every targeted light/switch in the new scene is currently off. Doesn't disturb a sleeping/relaxing user, refreshes a dark-but-occupied room. - defer: never re-apply on TOD flip; wait for the next vacancy cycle. Best for bedrooms with mmWave that always reports occupancy. - apply: always re-apply on TOD flip. Best for kitchens, hallways, transit areas. default: apply_if_lights_off selector: select: options: - apply_if_lights_off - defer - apply sleep_mode_switch: name: Sleep Mode Switch (optional) description: > input_boolean / binary_sensor that, when ON, forces the sleep scene and ignores TOD/presence. Useful for shift workers, daytime naps, or letting a partner enter without lighting up the room. Requires sleep_scene to be configured (otherwise the switch is silently ignored). default: "" selector: entity: domain: - binary_sensor - input_boolean # ------------------------------------------------------------------------- # Motion Light Coexistence (advanced) # ------------------------------------------------------------------------- motion_light_group: name: Motion Light Coexistence collapsed: true input: motion_light_state_entity: name: Motion Light State Entity (optional) description: > input_text used by a Motion Light blueprint instance that targets the same lights as this room. While Motion Light is actively controlling the lights (state ENABLING/ENABLED/ MANUAL), this blueprint stays out of its way. State changes on this entity also abort any in-flight scene application via mode: restart. default: "" selector: entity: domain: input_text motion_light_state_key: name: Motion Light State Key (optional) description: > JSON key used inside Motion Light's state entity. Leave empty to fall back to the room enable switch entity ID; set explicitly to match Motion Light's automation_state_placeholder_key. default: "" selector: text: # ------------------------------------------------------------------------- # Persistence # ------------------------------------------------------------------------- persistence_group: name: Persistence collapsed: true input: last_applied_state_entity: name: Last Applied Scene Entity (optional) description: > input_text used to remember the last scene this automation applied, so identical re-applications can be skipped. Each instance must have its own entity. Default max_length 100 is usually enough; ensure it fits the longest scene entity ID. Leave empty to disable the skip-if-same-scene guard. default: "" selector: entity: domain: input_text # ------------------------------------------------------------------------- # Callbacks # ------------------------------------------------------------------------- actions_group: name: Actions collapsed: true input: scene_applied_callback: name: Scene Applied Callback description: > Runs after a TOD or sleep scene is applied. Useful for notifications, dependent automations. default: [] selector: action: {} vacant_scene_applied_callback: name: Vacant Scene Applied Callback description: Runs after the vacant scene is applied. default: [] selector: action: {} # ------------------------------------------------------------------------- # Debug # ------------------------------------------------------------------------- debug_group: name: Debug collapsed: true input: enable_debug_notifications: name: Enable Debug Notifications description: > Send a persistent notification on every evaluation with the decision inputs and the resolved target scene. default: false selector: boolean: # Restart mode: rapid TOD/presence changes always use latest values, and # Motion Light state changes can abort an in-flight scene application. mode: restart # ============================================================================= # Triggers # ============================================================================= trigger: # Time-of-day state changed - platform: state entity_id: !input time_of_day_state id: tod_changed # Any presence sensor reports on (immediate) - platform: state entity_id: !input presence_sensors to: "on" id: presence_on # All presence sensors have been off for the configured timeout - platform: state entity_id: !input presence_sensors to: "off" for: seconds: !input presence_off_timeout id: presence_off # Room enable switch toggled - platform: state entity_id: !input room_enable_switch id: room_enable_changed # Sleep mode switch toggled - platform: state entity_id: !input sleep_mode_switch id: sleep_mode_changed # Motion Light state entity changed (abort signal via mode: restart) - platform: state entity_id: !input motion_light_state_entity id: motion_light_state_changed # ============================================================================= # Variables # ============================================================================= variables: # --------------------------------------------------------------------------- # Inputs # --------------------------------------------------------------------------- time_of_day_state: !input time_of_day_state presence_sensors: !input presence_sensors room_enable_switch: !input room_enable_switch scenes_list: !input scenes vacant_scene: !input vacant_scene sleep_scene: !input sleep_scene sleep_mode_switch: !input sleep_mode_switch tod_behavior_while_occupied: !input tod_behavior_while_occupied motion_light_state_entity: !input motion_light_state_entity motion_light_state_key_input: !input motion_light_state_key last_applied_state_entity: !input last_applied_state_entity enable_debug_notifications: !input enable_debug_notifications # --------------------------------------------------------------------------- # Trigger context # --------------------------------------------------------------------------- trigger_id: "{{ trigger.id | default('') }}" # --------------------------------------------------------------------------- # TOD resolution # --------------------------------------------------------------------------- tod_state: "{{ states(time_of_day_state) }}" tod_options: "{{ state_attr(time_of_day_state, 'options') or [] }}" tod_index: "{{ tod_options.index(tod_state) if tod_state in tod_options else -1 }}" tod_valid: "{{ tod_index >= 0 and tod_index < (scenes_list | length) }}" tod_scene: "{{ scenes_list[tod_index] if tod_valid else '' }}" # --------------------------------------------------------------------------- # Presence resolution (ANY-on logic) # Sensors reporting unavailable/unknown are treated as occupied — safer than # the alternative of plunging an actually-occupied room into vacant scene. # --------------------------------------------------------------------------- has_presence_sensors: "{{ (presence_sensors | length) > 0 }}" any_presence_on: > {% set sensors = presence_sensors if presence_sensors is iterable else [presence_sensors] %} {% set ns = namespace(on=false) %} {% for s in sensors %} {% set v = states(s) %} {% if v in ['on', 'home', 'true', '1', 'unavailable', 'unknown'] %} {% set ns.on = true %} {% endif %} {% endfor %} {{ ns.on }} is_occupied: "{{ (not has_presence_sensors) or any_presence_on }}" # --------------------------------------------------------------------------- # Room enable / sleep mode # --------------------------------------------------------------------------- room_enabled: > {% if room_enable_switch in [none, ''] %} true {% else %} {{ states(room_enable_switch) in ['on', 'home', 'true', '1'] }} {% endif %} sleep_active: > {% if sleep_mode_switch in [none, ''] or sleep_scene in [none, ''] %} false {% else %} {{ states(sleep_mode_switch) in ['on', 'home', 'true', '1'] }} {% endif %} # --------------------------------------------------------------------------- # Motion Light coexistence # State JSON shape (from Motion Light): # { "": { "mls": "0|1|2|3", "mllat": "...", "mllb": ... } } # mls: 0=NONE, 1=ENABLED, 2=ENABLING, 3=MANUAL. # --------------------------------------------------------------------------- motion_light_state_global: > {% if motion_light_state_entity in [none, ''] %} {{ dict() }} {% else %} {% set text = states(motion_light_state_entity) | string %} {% if text in ['unknown', 'unavailable', 'none', ''] %} {{ dict() }} {% else %} {% set parsed = text | from_json(default=dict()) %} {{ parsed if parsed is mapping else dict() }} {% endif %} {% endif %} motion_light_resolved_key: > {% if motion_light_state_key_input not in [none, ''] %} {{ motion_light_state_key_input }} {% else %} {{ room_enable_switch | default('', true) }} {% endif %} motion_light_room_state: > {% set inner = motion_light_state_global.get(motion_light_resolved_key, dict()) %} {{ (inner.get('mls', '0') | string) if inner is mapping else '0' }} motion_light_claimed: "{{ motion_light_room_state in ['1', '2', '3'] }}" # --------------------------------------------------------------------------- # Target scene (recomputed at apply time, never cached at trigger time) # --------------------------------------------------------------------------- target_scene: > {% if sleep_active %} {{ sleep_scene }} {% elif not room_enabled %} {{ vacant_scene }} {% elif is_occupied and tod_valid %} {{ tod_scene }} {% else %} {{ vacant_scene }} {% endif %} is_vacant_target: "{{ target_scene == vacant_scene }}" # --------------------------------------------------------------------------- # Skip-if-same-scene guard # --------------------------------------------------------------------------- last_applied_scene: > {% if last_applied_state_entity in [none, ''] %} '' {% else %} {{ states(last_applied_state_entity) | string }} {% endif %} # --------------------------------------------------------------------------- # `apply_if_lights_off` evaluation: are all entities in the target scene # currently off? Looks at the scene's entity_id attribute. # --------------------------------------------------------------------------- scene_lights_off: > {% set ns = namespace(any_on=false) %} {% if target_scene not in [none, ''] %} {% set entities = state_attr(target_scene, 'entity_id') or [] %} {% for e in entities %} {% if (e.startswith('light.') or e.startswith('switch.')) and is_state(e, 'on') %} {% set ns.any_on = true %} {% endif %} {% endfor %} {% endif %} {{ not ns.any_on }} # ============================================================================= # Actions # ============================================================================= action: # --------------------------------------------------------------------------- # Debug: log entry state # --------------------------------------------------------------------------- - choose: - conditions: "{{ enable_debug_notifications }}" sequence: - service: persistent_notification.create data: title: "Presence Scene Controller — entry" message: > Trigger: {{ trigger_id }} TOD: {{ tod_state }} (idx {{ tod_index }}) Occupied: {{ is_occupied }} | Enabled: {{ room_enabled }} | Sleep: {{ sleep_active }} ML claimed: {{ motion_light_claimed }} (mls={{ motion_light_room_state }}, key={{ motion_light_resolved_key }}) Target: {{ target_scene }} Last applied: {{ last_applied_scene }} # --------------------------------------------------------------------------- # Yield to Motion Light if it's currently controlling the room # --------------------------------------------------------------------------- - choose: - conditions: - condition: template value_template: "{{ motion_light_claimed }}" sequence: - stop: "Motion Light is controlling this room — yielding." # --------------------------------------------------------------------------- # Motion Light state-change is purely an abort signal. If we reach here and # ML is NOT claimed, do nothing — ML may have just released the room and # turned the lights off intentionally; we should not immediately re-apply. # --------------------------------------------------------------------------- - choose: - conditions: - condition: template value_template: "{{ trigger_id == 'motion_light_state_changed' }}" sequence: - stop: "Motion Light state changed — abort signal only." # --------------------------------------------------------------------------- # Validate TOD configuration # --------------------------------------------------------------------------- - choose: - conditions: - condition: template value_template: "{{ tod_index == -1 }}" sequence: - stop: "Invalid time-of-day state — current state not in options list." - conditions: - condition: template value_template: "{{ is_occupied and room_enabled and not sleep_active and not tod_valid }}" sequence: - stop: "Scene index out of range — scenes list shorter than TOD options." # --------------------------------------------------------------------------- # TOD-while-occupied policy. Only kicks in when this run was triggered by a # TOD flip AND the room is occupied AND no override mode is in effect. # --------------------------------------------------------------------------- - choose: - conditions: - condition: template value_template: > {{ trigger_id == 'tod_changed' and is_occupied and room_enabled and not sleep_active }} sequence: - choose: - conditions: - condition: template value_template: "{{ tod_behavior_while_occupied == 'defer' }}" sequence: - stop: "TOD changed while occupied — deferring per policy." - conditions: - condition: template value_template: > {{ tod_behavior_while_occupied == 'apply_if_lights_off' and not scene_lights_off }} sequence: - stop: "TOD changed while occupied with lights on — deferring." # --------------------------------------------------------------------------- # Skip if the target scene is already what we last applied # --------------------------------------------------------------------------- - choose: - conditions: - condition: template value_template: > {{ last_applied_scene != '' and target_scene not in [none, ''] and last_applied_scene == target_scene }} sequence: - stop: "Scene already applied — no-op." # --------------------------------------------------------------------------- # Apply the target scene # --------------------------------------------------------------------------- - choose: - conditions: - condition: template value_template: "{{ target_scene not in [none, ''] }}" sequence: - service: scene.turn_on target: entity_id: "{{ target_scene }}" # --------------------------------------------------------------------------- # Persist last-applied scene # --------------------------------------------------------------------------- - choose: - conditions: - condition: template value_template: > {{ last_applied_state_entity not in [none, ''] and target_scene not in [none, ''] }} sequence: - service: input_text.set_value target: entity_id: "{{ last_applied_state_entity }}" data: value: "{{ target_scene }}" # --------------------------------------------------------------------------- # Post-apply Motion Light re-check. Brief delay then re-read ML state — if # Motion Light claimed the room while we were applying, do not contest. # --------------------------------------------------------------------------- - delay: milliseconds: 300 - variables: ml_state_after: > {% if motion_light_state_entity in [none, ''] %} 0 {% else %} {% set text = states(motion_light_state_entity) | string %} {% if text in ['unknown', 'unavailable', 'none', ''] %} 0 {% else %} {% set parsed = text | from_json(default=dict()) %} {% if parsed is mapping %} {% set inner = parsed.get(motion_light_resolved_key, dict()) %} {{ (inner.get('mls', '0') | string) if inner is mapping else '0' }} {% else %} 0 {% endif %} {% endif %} {% endif %} - choose: - conditions: - condition: template value_template: "{{ (ml_state_after | string) in ['1', '2', '3'] }}" sequence: - choose: - conditions: "{{ enable_debug_notifications }}" sequence: - service: persistent_notification.create data: title: "Presence Scene Controller" message: "Motion Light claimed room post-apply — yielding without callback." - stop: "Motion Light took over after scene apply." # --------------------------------------------------------------------------- # Run the appropriate callback # --------------------------------------------------------------------------- - choose: - conditions: - condition: template value_template: "{{ is_vacant_target }}" sequence: !input vacant_scene_applied_callback default: !input scene_applied_callback