fafcf116be
- 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
627 lines
24 KiB
YAML
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
|