Files
haos-blueprints/Common/Presence Scene Controller/blueprint.yaml
T
alexei.dolgolyov fafcf116be feat: add Presence Scene Controller and Time Of Day Selector blueprints
- Presence Scene Controller: per-room presence-aware time-of-day scenes
  with vacant/sleep scenes and Motion Light coexistence
- Time Of Day Selector: event-driven state machine that sets an
  input_select when a configured time entity fires
- List both blueprints in the root README
- Bump version to 2.13.0
2026-05-27 13:10:03 +03:00

627 lines
24 KiB
YAML

# 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):
# { "<key>": { "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