Compare commits

4 Commits

Author SHA1 Message Date
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
alexei.dolgolyov 3d15f481a0 feat: add debug mode and clarify docs in MQTT Button Control
- Add optional debug notifications for trigger and light-branch decisions
- Document index-based action/entity mapping and two-topic support
- Add configuration reference table and debug-mode section to README
2026-05-27 13:09:59 +03:00
alexei.dolgolyov ad6f30ce3c feat: add unload reminder to Washing Machine
- Send a reminder N minutes after cycle completion, with optional repeats
- Auto-cancel reminders when a new cycle starts or a door/lid sensor opens
- Gate the completion timestamp on configured door sensors being closed
- Track completion time (cct) and reminder count (urc) in persistent state
2026-05-27 13:09:55 +03:00
alexei.dolgolyov 34cf5b1f7a feat: harden Motion Light manual-override and turn-off recovery
- Add motion-off debounce to filter PIR drop-outs
- Treat pre-emptive manual turn-on (from idle) as MANUAL mode
- Recover from external turn-off while motion still active
- Run enable/disable callbacks before turn-on/off for mode:restart safety
- Document brightness-threshold interaction and input_text max_length
2026-05-27 13:09:50 +03:00
12 changed files with 1585 additions and 180 deletions
+22 -12
View File
@@ -7,10 +7,10 @@ This blueprint creates a smart motion-activated light control system. It handles
- Multiple motion sensor support (triggers on ANY sensor)
- Condition switches (ALL must be ON for automation to work)
- Multiple lights and/or switches control
- Light groups and area-based targeting
- Light groups and area-based targeting (native area picker)
- Configurable timeout delay before turning off
- Minimum on duration (prevents rapid on/off cycling)
- Motion sensor debounce (filter false triggers)
- Motion sensor on/off debounce (filter false triggers and PIR drop-outs)
- Smooth light transitions with configurable duration
- Luminance sensor support (only trigger in dark conditions)
- Time-based conditions (only active during specified hours)
@@ -36,19 +36,29 @@ The automation tracks these states via persistent storage:
## Behavior Notes
- Will NOT turn on light if it's already ON (prevents hijacking user control)
- If user changes light while automation is active, enters MANUAL mode (after grace period)
- Grace period prevents false manual overrides from delayed device state reports (e.g., Zigbee)
- MANUAL mode exits when light is turned OFF (by any means)
- Timeout delay only applies when turning OFF (motion cleared)
- Time conditions support overnight windows (e.g., 22:00 to 06:00)
- Day/Night mode uses separate time window from time conditions
- Will NOT turn on light if it's already ON (prevents hijacking user control).
- If user **changes** the light while automation is active (e.g., dim, color, scene), enters MANUAL mode (after grace period).
- If the user **turns the light ON** while the automation is idle (pre-emptive manual control), it enters MANUAL mode and fires the `manual_action` callback. The `disable_action` callback is **not** run in this case (nothing was enabled to disable).
- If the light is **turned off** by any external source while motion is still active, the automation re-enables it (external turn-off recovery). To intentionally exit the automation, also turn off the corresponding **condition switch** — this routes through the disable path and respects the configured timeouts.
- Grace period prevents false manual overrides from delayed device state reports (e.g., Zigbee) — including stray reports that can follow a turn-off.
- MANUAL mode exits when light is turned OFF (by any means).
- Timeout delay only applies when turning OFF (motion cleared).
- Time conditions support overnight windows (e.g., 22:00 to 06:00).
- Day/Night mode uses separate time window from time conditions.
### Brightness threshold interaction
`brightness_threshold` is checked only when the automation decides whether to **enable**. A light that is on but dimmer than the threshold is treated as "available to take over" — so the automation will run with that light. When the disable path runs, it will turn the light **off** (it does not restore a below-threshold dim state).
### Callback ordering and `mode: restart`
Light state changes feed back into the automation, which uses `mode: restart` to handle rapid bursts. To make sure callbacks aren't silently dropped when the automation restarts mid-action, both `enable_action` and `disable_action` run **before** the corresponding turn-on/turn-off command.
## Requirements
- At least one motion sensor
- `input_text` entity for persistent state storage
- Target light(s), switch(es), group, or area to control
- At least one motion sensor.
- `input_text` entity for persistent state storage. Each automation instance must have its own entity. Increase the entity's `max_length` (default 100) to **at least 255** to leave room for the JSON state — short values can cause silent truncation.
- Target light(s), switch(es), group, or area to control.
## Author
+189 -101
View File
@@ -92,15 +92,15 @@ blueprint:
target_area:
name: Target Area (optional)
description: >
Area ID (e.g., "living_room"). All lights and switches in the
area will be discovered and controlled.
default: ""
Pick an area. All lights and switches in the area will be
discovered and controlled.
default:
selector:
text:
area: {}
target_light_data:
name: Light Data Dictionary (optional)
default: ""
default: {}
description: >
Provide a YAML dictionary of light.turn_on parameters.
If not specified, the light's last settings are preserved.
@@ -289,14 +289,14 @@ blueprint:
day_light_data:
name: Day Light Settings
description: Light parameters during day mode (YAML dictionary)
default: ""
default: {}
selector:
object: {}
night_light_data:
name: Night Light Settings
description: Light parameters during night mode (YAML dictionary)
default: ""
default: {}
selector:
object: {}
@@ -385,6 +385,20 @@ blueprint:
step: 1
unit_of_measurement: "seconds"
motion_off_debounce:
name: Motion Off Debounce (seconds)
description: >
Motion sensor must report 'off' for this duration before the
disable path starts evaluating. Filters out brief sensor
drop-outs (common with PIR sensors). Set to 0 to disable.
default: 0
selector:
number:
min: 0
max: 30
step: 1
unit_of_measurement: "seconds"
manual_override_grace_period:
name: Manual Override Grace Period (seconds)
description: >
@@ -455,7 +469,9 @@ blueprint:
manual_action:
name: Manual callback action (optional)
description: >
Runs when user manually changes the light while automation is active.
Runs when the user takes manual control of the light: either by
changing it while the automation is active, or by turning it ON
while the automation is idle (pre-emptive manual control).
Requires 'Automation state entity' to be configured.
default: []
selector:
@@ -479,10 +495,12 @@ trigger:
seconds: !input motion_debounce
id: "motion_sensor_on"
# Motion sensors OFF
# Motion sensors OFF (with debounce to filter PIR drop-outs)
- platform: state
entity_id: !input motion_sensors
to: "off"
for:
seconds: !input motion_off_debounce
id: "motion_sensor_off"
# Condition switches ON/OFF
@@ -499,20 +517,22 @@ trigger:
entity_id: !input target_switches
id: "switch_state_changed"
# Luminance sensor value changed
# Luminance dropped below threshold (re-evaluate, may now enable)
# Template triggers fire on false→true transition, so this only fires when
# the sensor *crosses* the threshold downward — exactly when we care.
- platform: template
value_template: >
{% if luminance_sensor %}
{{ states(luminance_sensor) not in ['unknown','unavailable'] }}
{{ states(luminance_sensor) | float(99999) < (luminance_threshold | float(50)) }}
{% else %}
false
{% endif %}
# Luminance enable switch changed
# Luminance enable switch turned ON (re-evaluate)
- platform: template
value_template: >
{% if luminance_enable_switch %}
{{ states(luminance_enable_switch) not in ['unknown','unavailable'] }}
{{ is_state(luminance_enable_switch, 'on') }}
{% else %}
false
{% endif %}
@@ -531,7 +551,6 @@ variables:
# State Machine Constants
# ---------------------------------------------------------------------------
# These define the possible automation states stored in persistent storage
automation_state_invalid: '-1' # Error state
automation_state_none: '0' # Idle, waiting for motion
automation_state_enabled: '1' # Light is ON and controlled by automation
automation_state_enabling: '2' # Turn-on command sent, awaiting confirmation
@@ -576,7 +595,7 @@ variables:
{% if target_light_group is not none %}
{% set result = result + [target_light_group] %}
{% endif %}
{% if target_area != '' %}
{% if target_area is not none and target_area != '' %}
{% set area_lights = area_entities(target_area) | select('match', '^light\\.') | list %}
{% set result = result + area_lights %}
{% endif %}
@@ -594,7 +613,7 @@ variables:
{% if target_switches | length > 0 %}
{% set result = result + target_switches %}
{% endif %}
{% if target_area != '' %}
{% if target_area is not none and target_area != '' %}
{% set area_switches = area_entities(target_area) | select('match', '^switch\\.') | list %}
{% set result = result + area_switches %}
{% endif %}
@@ -631,13 +650,16 @@ variables:
# ---------------------------------------------------------------------------
automation_state_entity: !input automation_state_entity
# Parse global state JSON from input_text entity
# Parse global state JSON from input_text entity.
# Tolerant of empty/unknown/unavailable values and corrupt JSON
# (truncation can occur if input_text max_length is too short).
automation_state_global: >
{% set text = states(automation_state_entity) | string %}
{% if text in ['unknown','unavailable','none',''] %}
{{ dict() }}
{% else %}
{{ text | from_json }}
{% set parsed = text | from_json(default=dict()) %}
{{ parsed if parsed is mapping else dict() }}
{% endif %}
automation_state_placeholder_key: !input automation_state_placeholder_key
@@ -655,20 +677,11 @@ variables:
{% endif %}
# Get this automation's state from global state
# BUG FIX: Changed from 'light_entity != ""' to proper none check
automation_state: "{{ automation_state_global.get(automation_state_key, dict()) if (light_entity is not none or switch_entity is not none) else dict() }}"
# Current state machine state
motion_light_state: "{{ automation_state.get(state_motion_light_state, automation_state_none) }}"
# Track last action timestamp
motion_light_last_action_timestamp: >
{% if trigger_id == 'state_motion' %}
{{ date_time_now }}
{% else %}
{{ automation_state.get(state_motion_light_last_action_timestamp, none) }}
{% endif %}
# State machine state checks (for readability)
state_is_none: "{{ (motion_light_state | string) == automation_state_none }}"
state_is_enabled: "{{ (motion_light_state | string) == automation_state_enabled }}"
@@ -927,28 +940,31 @@ action:
- conditions:
- condition: template
value_template: >
{# Check actual on/off state only — do NOT use brightness_threshold here.
Brightness threshold is for the enable guard (any_device_on), not for
detecting whether the light was actually turned off. During transitions,
brightness may temporarily be below threshold while the light is still on. #}
{% set res = true %}
{% if light_entity is not none %}
{% set res = res and is_state(light_entity, 'off') %}
{% endif %}
{% if switch_entity is not none %}
{% set res = res and is_state(switch_entity, 'off') %}
{% endif %}
{# Only true if we have at least one device and all are off #}
{{ res and (light_entity is not none or switch_entity is not none) }}
{# Check actual on/off state across ALL resolved devices — do NOT use
brightness_threshold here. Threshold is for the enable guard
(any_device_on), not for detecting actual off. During transitions,
brightness may temporarily dip below threshold while still on. #}
{% set has_devices = (resolved_all_lights | length > 0) or (resolved_all_switches | length > 0) %}
{% set lights_on = resolved_all_lights | select('is_state', 'on') | list | length > 0 %}
{% set switches_on = resolved_all_switches | select('is_state', 'on') | list | length > 0 %}
{{ has_devices and not lights_on and not switches_on }}
sequence:
# Reset state to NONE
# Reset state to NONE and stamp the action timestamp.
# Every turn-off (automation OR user) passes through here, so
# stamping now starts the grace window that protects the
# pre-emptive manual-ON detection (NONE -> MANUAL) below from
# stray off->on device reports that can follow a turn-off
# (common with Zigbee bulbs + transitions).
- service: input_text.set_value
target:
entity_id: "{{ automation_state_entity }}"
data:
value: >
{% set new_automation_state = (automation_state | combine({ state_motion_light_state: automation_state_none })) %}
{% set new_automation_state = (automation_state | combine({
state_motion_light_state: automation_state_none,
state_motion_light_last_action_timestamp: date_time_now
})) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Re-evaluate: if an external source turned off the light while
@@ -986,7 +1002,8 @@ action:
{% if text in ['unknown','unavailable','none',''] %}
{{ dict() }}
{% else %}
{{ text | from_json }}
{% set parsed = text | from_json(default=dict()) %}
{{ parsed if parsed is mapping else dict() }}
{% endif %}
re_eval_state: "{{ re_eval_state_global.get(automation_state_key, dict()) }}"
re_eval_motion_light_state: "{{ re_eval_state.get(state_motion_light_state, automation_state_none) }}"
@@ -997,7 +1014,18 @@ action:
# --- Re-enable path (mirrors CASE 2) ---
# Set state to ENABLING before turning on
# Guard: scene mode without resolved scene
- choose:
- conditions:
- condition: template
value_template: "{{ use_scene_instead and effective_scene is none }}"
sequence:
- stop: "Scene mode enabled but no scene configured for current mode"
# Set state to ENABLING before turning on.
# last_brightness=0 is correct here: this path runs after the
# light was turned off externally, so there's no meaningful
# brightness to capture.
- service: input_text.set_value
target:
entity_id: "{{ automation_state_entity }}"
@@ -1005,12 +1033,33 @@ action:
value: >
{% set new_automation_state = (re_eval_state | combine({
state_motion_light_state: automation_state_enabling,
state_motion_light_last_action_timestamp: now(),
state_motion_light_last_action_timestamp: date_time_now,
state_motion_light_last_brightness: 0
})) %}
{{ re_eval_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Scene or light activation
# Run enable callback BEFORE turning on (mode:restart safety)
- choose:
- conditions:
- condition: template
value_template: "{{ enable_action != [] }}"
sequence: !input enable_action
# Debug notification (also before turn-on for restart safety)
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug"
message: >
Action: RE-ENABLE (external turn-off recovery)
Time: {{ now().strftime('%H:%M:%S') }}
Lights: {{ resolved_all_lights }}
Switches: {{ resolved_all_switches }}
Scene: {{ effective_scene if use_scene_instead else 'N/A' }}
# Scene or light activation (after callback/debug for restart safety)
- choose:
- conditions:
- condition: template
@@ -1049,27 +1098,6 @@ action:
target:
entity_id: "{{ resolved_all_switches }}"
# Execute enable callback
- choose:
- conditions:
- condition: template
value_template: "{{ enable_action != [] }}"
sequence: !input enable_action
# Debug notification
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug"
message: >
Action: RE-ENABLE (external turn-off recovery)
Time: {{ now().strftime('%H:%M:%S') }}
Lights: {{ resolved_all_lights }}
Switches: {{ resolved_all_switches }}
Scene: {{ effective_scene if use_scene_instead else 'N/A' }}
# ----- Sub-case: Automation just turned on the light -----
# Transition from ENABLING to ENABLED, or disable immediately
# if motion already cleared during the ENABLING phase
@@ -1160,27 +1188,36 @@ action:
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# ----- Sub-case: User manually changed the light -----
# Transition from ENABLED to MANUAL (user took control)
# Transition to MANUAL (user took control). Fires when EITHER:
# * state is ENABLED and the user changes the light, OR
# * state is NONE and the user turns the light ON while the
# automation is idle (pre-emptive manual control).
# Only triggers on meaningful state changes (on→off or off→on),
# NOT on attribute-only updates (on→on) which Zigbee devices
# commonly send as the light settles after a transition.
# Grace period: ignore state changes shortly after the automation
# turns on the light to avoid false manual override detection.
# In NONE state, only a turn-ON counts as a takeover (a turn-OFF
# is handled by the "Light turned OFF" sub-case above).
# Grace period: ignore state changes shortly after the automation's
# last action to avoid false manual override detection.
- conditions:
- condition: template
value_template: >
{% set meaningful_change = trigger.from_state.state != trigger.to_state.state %}
{% if not meaningful_change %}
{{ false }}
{% else %}
{% elif state_is_none and trigger.to_state.state != 'on' %}
{{ false }}
{% elif state_is_enabled or state_is_none %}
{% set last_ts = automation_state.get(state_motion_light_last_action_timestamp, none) %}
{% set grace = (transition_duration | float(0)) + (manual_override_grace_period | float(2)) %}
{% if state_is_enabled and last_ts is not none %}
{% if last_ts is none %}
{{ true }}
{% else %}
{% set parsed = last_ts | as_datetime %}
{{ parsed is none or (now() - parsed).total_seconds() > grace }}
{% else %}
{{ state_is_enabled }}
{% endif %}
{% else %}
{{ false }}
{% endif %}
sequence:
# BUG FIX: Fixed YAML structure - was 'data: >' instead of 'data:' with 'value: >'
@@ -1192,9 +1229,12 @@ action:
{% set new_automation_state = (automation_state | combine({ state_motion_light_state: automation_state_manual })) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Call disable action if configured
# Call disable action if configured.
# Only when we were actively ENABLED — a pre-emptive manual ON
# (from NONE) never enabled anything, so there is nothing to
# "disable" and running it could touch unrelated devices.
- choose:
- conditions: "{{ manual_action_runs_disable_action and disable_action != [] }}"
- conditions: "{{ state_is_enabled and manual_action_runs_disable_action and disable_action != [] }}"
sequence: !input disable_action
# Call manual action callback
@@ -1212,7 +1252,7 @@ action:
message: >
Action: MANUAL OVERRIDE
Time: {{ now().strftime('%H:%M:%S') }}
Previous State: ENABLED
Previous State: {{ 'ENABLED' if state_is_enabled else 'NONE (pre-emptive)' }}
New State: MANUAL
Trigger: {{ trigger_id }}
@@ -1227,6 +1267,33 @@ action:
- condition: template
value_template: "{{ (state_is_enabled or state_is_enabling) and must_be_disabled_preview }}"
sequence:
# Honor min_on_duration even on the recovery path so a
# mistimed Zigbee report can't chop the on-time short.
# We skip timeout_delay here intentionally — this path
# is the recovery for a cancelled CASE 3, and the user
# already paid that delay before the restart.
- variables:
time_since_enabled: >
{% set last_ts = automation_state.get(state_motion_light_last_action_timestamp, none) %}
{% if last_ts is none %}
{{ 9999 }}
{% else %}
{% set parsed = last_ts | as_datetime %}
{% if parsed is none %}
{{ 9999 }}
{% else %}
{{ (now() - parsed).total_seconds() | int }}
{% endif %}
{% endif %}
remaining_min_on: "{{ [0, min_on_duration - time_since_enabled] | max }}"
- choose:
- conditions:
- condition: template
value_template: "{{ remaining_min_on > 0 }}"
sequence:
- delay:
seconds: "{{ remaining_min_on }}"
# Reset state to NONE first (before turn-off triggers another restart)
- service: input_text.set_value
target:
@@ -1309,15 +1376,33 @@ action:
# Enable the light/switch
default:
# Store current brightness (to restore later if configured)
# Store current brightness (to restore later if configured).
# If light is on but below brightness_threshold, treat as "off"
# for restore purposes — otherwise the disable path would
# restore that dim state instead of actually turning off.
- variables:
last_brightness: >
{% if reference_light is none or is_state(reference_light, 'off') %}
{{ 0 }}
{% else %}
{{ state_attr(reference_light, 'brightness') | int(0) }}
{% set br = state_attr(reference_light, 'brightness') | int(0) %}
{% set thr = brightness_threshold | int(0) %}
{% if thr > 0 and br < thr %}
{{ 0 }}
{% else %}
{{ br }}
{% endif %}
{% endif %}
# Guard: scene mode is on but no scene resolved — stop loudly
# rather than silently falling back to lights with default data.
- choose:
- conditions:
- condition: template
value_template: "{{ use_scene_instead and effective_scene is none }}"
sequence:
- stop: "Scene mode enabled but no scene configured for current mode"
# Update state to ENABLING BEFORE turning on the light.
# This must happen first because mode: restart may cancel
# subsequent steps if the light state change fires immediately.
@@ -1333,6 +1418,31 @@ action:
})) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Run enable callback BEFORE turning on the light.
# Light state change fires mode:restart which cancels remaining
# steps — running the callback first ensures it isn't silently
# dropped (mirrors the CASE 3 disable_action ordering).
- choose:
- conditions:
- condition: template
value_template: "{{ enable_action != [] }}"
sequence: !input enable_action
# Debug notification (also before turn-on for restart safety)
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug"
message: >
Action: ENABLE
Time: {{ now().strftime('%H:%M:%S') }}
Lights: {{ resolved_all_lights }}
Switches: {{ resolved_all_switches }}
Scene: {{ effective_scene if use_scene_instead else 'N/A' }}
Night Mode: {{ is_night_mode }}
# Scene activation path
- choose:
- conditions:
@@ -1373,28 +1483,6 @@ action:
target:
entity_id: "{{ resolved_all_switches }}"
# Execute enable callback action
- choose:
- conditions:
- condition: template
value_template: "{{ enable_action != [] }}"
sequence: !input enable_action
# Debug notification
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug"
message: >
Action: ENABLE
Time: {{ now().strftime('%H:%M:%S') }}
Lights: {{ resolved_all_lights }}
Switches: {{ resolved_all_switches }}
Scene: {{ effective_scene if use_scene_instead else 'N/A' }}
Night Mode: {{ is_night_mode }}
# -----------------------------------------------------------------------
# CASE 3: Disable Path (Motion Cleared, Should Turn Off)
# -----------------------------------------------------------------------
+158
View File
@@ -0,0 +1,158 @@
# Presence Scene Controller Blueprint
A per-room, presence-aware time-of-day scene controller. Maps scenes to time-of-day options by index — like the [Day Scene Controller](../Day%20Scene%20Controller/README.md) — but adds presence gating, vacant/sleep scenes, and explicit coexistence with the [Motion Light](../Motion%20Light/README.md) blueprint.
Designed to take over the **per-room** responsibilities that have outgrown a single house-wide scene per time slot. Day Scene Controller can be kept alongside this blueprint for genuinely house-wide scenes (Away, Goodnight, All-Off).
## Why this exists
`Day Scene Controller` triggers one global scene per time-of-day slot. As a home grows, those scenes balloon to dozens of entities and start fighting the user — empty bedrooms light up at sunset, scenes overwrite whatever is happening in the room you're actually in, and a single light requires editing four monolithic scenes.
This blueprint splits the problem per room. Each room gets its own small scenes (36 entities), its own presence sensors, its own vacancy behavior, and a clean handoff with motion-driven automations.
## Architecture
```
Time of Day Selector (input_select, 1 instance)
├──► Day Scene Controller (house-wide, kept for Away/Goodnight)
└──► Presence Scene Controller × N (one instance per room)
```
Per room the user provides:
- A small set of scenes (Morning/Day/Evening/Night, plus a Vacant scene).
- Optional presence sensors.
- Optional sleep-mode switch + sleep scene.
- Optional Motion Light state entity for coexistence.
## Migration path
The blueprint is built so you can adopt it gradually:
1. **Day 1.** Instantiate the blueprint per room with **no presence sensors** and small per-room scenes. With no sensors the room is treated as always-occupied and behaves as a per-room TOD mapper — you immediately gain smaller scenes and per-room control without buying any new hardware.
2. **Day N.** Add presence sensors to one room (start with the bedroom — biggest UX win). Append the sensor entity to the input list. Vacancy gating, the sleep-person fix, and TOD-defer all activate for that room. No other config changes.
3. Repeat per room as you wire more sensors. Day Scene Controller can stay around for the house-wide scenes that still make sense, or be retired completely.
## How it works
### Triggers
The automation re-evaluates on any of:
- Time-of-day state change.
- Any presence sensor reporting `on`.
- All presence sensors reporting `off` for the configured timeout.
- Room enable switch toggled.
- Sleep mode switch toggled.
- Motion Light state entity changed (used as an abort signal).
### Decision flow
On every run, in order:
1. **Yield to Motion Light.** If the configured Motion Light state entity reports the same room is in `ENABLING`/`ENABLED`/`MANUAL`, stop immediately.
2. **Motion-Light-state-change abort.** If this run was triggered by a Motion Light state change and Motion Light is now released, stop without re-applying anything (avoids fighting a freshly-disabled Motion Light).
3. **Validate TOD.** Stop if the time-of-day state is not in the options list, or if the scenes list is shorter than the options.
4. **Apply TOD-while-occupied policy** (only when the trigger was a TOD flip and the room is currently occupied):
- `defer` — stop, wait for the next vacancy cycle.
- `apply_if_lights_off` — stop unless every light/switch in the target scene is currently off.
- `apply` — continue.
5. **Skip if same scene.** If a `last_applied_state_entity` is configured and the target scene matches what was last applied, stop.
6. **Apply the target scene.**
7. **Persist last-applied scene** (if a state entity is configured).
8. **Post-apply re-check.** After 300 ms re-read Motion Light state; if Motion Light has since claimed the room, stop without running callbacks.
9. **Run callback**`vacant_scene_applied_callback` or `scene_applied_callback` depending on which scene was applied.
### Target-scene resolution
Computed at action time (not trigger time), so a TOD flip that fires while a vacancy timeout is still counting down resolves to the new TOD's scene at apply time:
| Condition | Target scene |
|---|---|
| Sleep mode switch ON (and sleep scene configured) | sleep_scene |
| Room enable switch OFF | vacant_scene |
| Occupied and TOD index valid | scenes[tod_index] |
| Vacant | vacant_scene |
### "TOD change while occupied" — the headline rule
Three modes for the toggle, default `apply_if_lights_off`:
| Mode | What it does | Best for |
|---|---|---|
| `apply_if_lights_off` | Re-apply only if every light/switch in the target scene is currently off | **Default** — handles "sleeping at 06:00 in a dark room" and "kitchen is dark, refresh it" with a single rule. Doesn't fight a user who is already reading with the lamp on. |
| `defer` | Never re-apply on TOD flip; wait for the next vacancy cycle | Bedrooms with mmWave that always reports occupancy. |
| `apply` | Always re-apply on TOD flip | Kitchen, hallway, transit areas. |
The deferred TOD scene is delivered automatically the next time the room transitions vacant→occupied — when you walk back in, you see the new TOD's scene.
### Vacancy
When all presence sensors have been off for `presence_off_timeout`, the `vacant_scene` is applied. Typical choices:
- An "All Off" scene for hallways and bathrooms.
- A dim night-light scene for the bedroom.
- An empty-room scene that turns off only the discretionary lights.
If no presence sensors are configured, vacancy is never reached and `vacant_scene` is effectively unused (still required as input, but ignored).
### Sleep mode
When `sleep_mode_switch` is ON (and `sleep_scene` is configured), every run resolves to `sleep_scene` regardless of TOD or presence. Use it for:
- Shift workers sleeping during the day.
- Naps where TOD says "Afternoon" but you want the room dark.
- A partner entering the bedroom without lighting it up.
The switch can be wired to a dashboard button, an NFC tag, a voice command, an alarm-clock automation that flips it off in the morning, or a sleep tracker.
### Motion Light coexistence
If both Motion Light and Presence Scene Controller target the same lights, point Presence Scene Controller at Motion Light's state `input_text` plus the matching key (Motion Light's `automation_state_placeholder_key`, or — by default — the room enable switch entity ID).
Coexistence is enforced two ways:
- **Yield gate.** Every evaluation reads Motion Light's state JSON. If it reports `ENABLING/ENABLED/MANUAL` for this room, we stop.
- **mode: restart abort.** A change on the Motion Light state entity is one of the triggers, so a transition into ENABLING during a scene application cancels the in-flight run via Home Assistant's restart mode.
- **Post-apply re-check.** A 300 ms delay after `scene.turn_on` is followed by a fresh read of Motion Light state. If Motion Light claimed the room during the apply, we yield without calling the success callback.
This is advisory rather than a hard lock — Home Assistant doesn't expose CAS-style locking — but it eliminates the common race in practice.
### Presence semantics
- ANY-on logic: the room is occupied if any presence sensor reports on.
- Sensors reporting `unavailable` or `unknown` are treated as **occupied** — safer than the alternative (briefly plunging a real-occupied room into the vacant scene during a Zigbee hiccup).
- `presence_off_timeout` debounces motion drop-outs and short absences.
## Configuration
| Group | Input | Notes |
|-------|-------|-------|
| **States** | Time of Day State Selector | Required. The shared `input_select`. |
| | Room Enable Switch | Optional kill switch. OFF → apply vacant scene. |
| **Presence** | Presence Sensors | Optional list. Empty = always occupied. |
| | Presence Off Timeout | Seconds (default 120). |
| **Scenes** | Time-of-Day Scenes | Required ordered list, one per TOD option. |
| | Vacant Scene | Required. Applied on vacancy / room disabled. |
| | Sleep Scene | Optional. Required for sleep mode to do anything. |
| **Behavior** | TOD Change Behavior While Occupied | `apply_if_lights_off` (default) / `defer` / `apply`. |
| | Sleep Mode Switch | Optional. |
| **Motion Light Coexistence** | Motion Light State Entity | Optional `input_text` from a Motion Light instance. |
| | Motion Light State Key | Optional override of the JSON key. |
| **Persistence** | Last Applied Scene Entity | Optional `input_text` for skip-if-same-scene. |
| **Actions** | Scene Applied Callback | Runs after a TOD/sleep scene is applied. |
| | Vacant Scene Applied Callback | Runs after the vacant scene is applied. |
| **Debug** | Enable Debug Notifications | Posts a persistent notification per evaluation. |
## Behavior notes & known limitations
- **HA restart while occupied.** After a restart, the first vacancy cycle applies the *current* TOD's scene to the room. If TOD already advanced past several boundaries during the restart, intermediate scenes are not retroactively applied.
- **Identical vacant scene and TOD scene.** If you accidentally set them to the same entity, the skip-if-same-scene guard suppresses redundant work — but only if you've configured a `last_applied_state_entity`.
- **Motion Light coexistence is advisory.** A worst-case race between a scene apply and Motion Light's enable path can still produce a brief flicker before the post-apply re-check yields. If you need stricter mutual exclusion, wire a per-room "scene controller idle" `input_boolean` into Motion Light's `condition_switches`.
- **Settled occupancy isn't modeled.** A user who rolls out of bed but stays in the room never triggers a vacancy cycle, so the deferred TOD scene won't apply until they leave and re-enter. Acceptable in practice; the first bathroom trip resets it.
- **One blueprint instance per room.** If you need separate behavior for two distinct light sets in the same room (e.g., bedroom main lights vs. bedroom fan + reading lamp), instantiate the blueprint twice with non-overlapping scenes.
- **Optional state-trigger entities.** Triggers reference `room_enable_switch`, `sleep_mode_switch`, and `motion_light_state_entity`. Leaving them empty is supported by modern Home Assistant; on older versions you may see a non-fatal warning in the log. The automation continues to work either way.
## Author
Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
@@ -0,0 +1,626 @@
# 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
+51
View File
@@ -0,0 +1,51 @@
# Time of Day Selector Blueprint
Event-driven blueprint that updates an `input_select` based on a list of time triggers. Each trigger entity is mapped to a state name; when the trigger fires, the matching state becomes active.
This is a sibling to the [Time Of Day Controller](../Time%20Of%20Day%20Controller/README.md) blueprint, which uses minute-by-minute polling and threshold comparison. Use this one when you want exact event-based firing and the ability to attach multiple trigger sources (e.g. a sun event **or** a manual forced time) to the same state.
## How It Works
- Define a list of **time triggers**`input_datetime` helpers and/or timestamp sensors such as `sensor.sun_next_rising` / `sensor.sun_next_dusk` / `sensor.sun_next_midnight`.
- Define a parallel list of **state names** — one per trigger, in the same order.
- When any trigger fires, the blueprint looks up the firing entity in the trigger list and selects the matching state name on the target `input_select`.
- Duplicate state names produce **OR-override** behavior: whichever associated trigger fires first wins.
## Index Mapping
The two lists are matched by position:
| Index | Trigger entity | State name |
|-------|---------------|------------|
| 0 | `sensor.sun_next_rising` | `Утро` |
| 1 | `input_datetime.dnevnoe_vremia` | `День` |
| 2 | `sensor.sun_next_dusk` | `Вечер` |
| 3 | `input_datetime.vechernee_vremia_prinuditelno` | `Вечер` |
| 4 | `sensor.sun_next_midnight` | `Ночь` |
| 5 | `input_datetime.polnochnoe_vremia_prinuditelno` | `Ночь` |
In this example, `Вечер` is set when either the natural dusk time or the forced datetime fires — whichever comes first.
## Supported Time Sources
- `input_datetime` entities (time-only: `HH:MM:SS`).
- Timestamp sensors that report an ISO datetime (e.g. `sensor.sun_next_rising`).
## Configuration
| Input | Description |
|-------|-------------|
| **Time of Day Selector** | The `input_select` entity to update. Its options must include every state name in the list below. |
| **Time of Day Triggers** | Ordered list of trigger entities. |
| **Time of Day States** | Parallel list of state names, same length as triggers. |
| **Debug mode** | When enabled, posts a persistent notification on each fire with the firing entity, resolved state, and previous state. |
## Behavioral Notes
- `input_select.select_option` is idempotent — repeated triggers for the same state cause no extra state changes.
- A guard condition skips the action if the resolved state already matches the current `input_select` value.
- On Home Assistant restart, the state is **not** retroactively corrected — it updates on the next trigger. If you need self-correcting state on restart, use the [Time Of Day Controller](../Time%20Of%20Day%20Controller/README.md) (polling) blueprint instead.
## Author
Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
+147
View File
@@ -0,0 +1,147 @@
# Time of Day Selector Blueprint
# Event-driven: sets an input_select when any configured time entity fires.
# See README.md for detailed documentation.
#
# Author: Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
blueprint:
name: "Custom: Time of Day Selector"
description: >
Event-driven time-of-day state machine.
Maps a list of time entities to a parallel list of state names.
When any time entity fires, the corresponding state is selected on the
target input_select. Multiple entities mapping to the same state name
produce OR-override behavior (e.g. sun_next_dusk OR a forced
input_datetime — whichever fires first wins).
domain: automation
input:
# -------------------------------------------------------------------------
# Output Configuration
# -------------------------------------------------------------------------
tod_select:
name: Time of Day Selector
description: >
The input_select entity whose option will be updated.
Its options must include every state name listed below.
selector:
entity:
domain: input_select
# -------------------------------------------------------------------------
# Trigger / State Mapping
# -------------------------------------------------------------------------
tod_times:
name: Time of Day Triggers
description: >
Ordered list of time entities. Each fires when its configured time
is reached. Accepts input_datetime (time-only) and timestamp sensors
(e.g. sensor.sun_next_rising, sensor.sun_next_dusk).
selector:
entity:
domain:
- input_datetime
- sensor
multiple: true
tod_states:
name: Time of Day States
description: >
Parallel list of state names — one entry for each trigger above, in
the same order. Each name must match an option of the target
input_select exactly. Repeat a name to give that state multiple
trigger sources (override / "whichever fires first" pattern).
selector:
text:
multiple: true
# -------------------------------------------------------------------------
# Debug
# -------------------------------------------------------------------------
is_debug:
name: Debug mode
description: >
When enabled, posts a persistent notification on each fire showing
the firing entity, resolved state, and previous state.
default: false
selector:
boolean:
# Single mode is sufficient: select_option is idempotent and trigger collisions
# are extremely rare (different time entities reaching the same instant).
mode: single
# =============================================================================
# Triggers
# =============================================================================
trigger:
- platform: time
at: !input tod_times
# =============================================================================
# Variables
# =============================================================================
variables:
tod_select: !input tod_select
tod_times: !input tod_times
tod_states: !input tod_states
is_debug: !input is_debug
# Index of the entity that fired, within tod_times. -1 if not found.
fired_index: >
{% set ns = namespace(idx=-1) %}
{% for e in tod_times %}
{% if e == trigger.entity_id %}
{% set ns.idx = loop.index0 %}
{% endif %}
{% endfor %}
{{ ns.idx }}
# State name resolved from the firing entity. Empty if lookup failed
# (e.g. mismatched list lengths or unknown trigger entity).
target_state: >
{%- if fired_index >= 0 and fired_index < tod_states | length -%}
{{ tod_states[fired_index] }}
{%- endif -%}
previous_state: "{{ states(tod_select) }}"
# =============================================================================
# Condition - Only proceed if a valid state was resolved and it differs
# =============================================================================
condition:
- condition: template
value_template: "{{ target_state | length > 0 and target_state != previous_state }}"
# =============================================================================
# Actions
# =============================================================================
action:
# ---------------------------------------------------------------------------
# Debug Logging (optional)
# ---------------------------------------------------------------------------
- choose:
- conditions:
- condition: template
value_template: "{{ is_debug }}"
sequence:
- service: persistent_notification.create
data:
title: "Time of Day Selector"
message: >
Fired entity: {{ trigger.entity_id }}
Index in triggers list: {{ fired_index }}
Previous state: {{ previous_state }}
Target state: {{ target_state }}
# ---------------------------------------------------------------------------
# Update Time of Day State
# ---------------------------------------------------------------------------
- service: input_select.select_option
target:
entity_id: "{{ tod_select }}"
data:
option: "{{ target_state }}"
+16 -1
View File
@@ -7,6 +7,7 @@ This blueprint monitors washing machine or dryer appliances and sends notificati
- Start notification with cycle duration, estimated end time, and mode details
- Completion notification (reminder to unload clothes) with energy report
- "Almost done" notification (configurable minutes before end)
- Unload reminder (configurable delay, optional repeats) — auto-cancelled when a new cycle starts or a configured door/lid sensor opens
- Pause/Resume notifications (detect when cycle is paused or resumed)
- Error message notifications
- Preparation mode notification (e.g., for dryer prep)
@@ -39,6 +40,8 @@ The automation tracks the appliance through these states:
| `cst` | Cycle Start Time (ISO timestamp) |
| `esmp` | Energy Samples accumulator (Wh) |
| `lst` | Last Sample Time (ISO timestamp) |
| `cct` | Cycle Completion Time (ISO timestamp, drives unload reminder) |
| `urc` | Unload Reminder Count (number of reminders already sent) |
## Message Template Variables
@@ -49,7 +52,7 @@ All message templates support these placeholder variables (use single braces):
| `{appliance_name}` | Device name (e.g., "Washing Machine") |
| `{remaining}` | Remaining time as string (e.g., "01:30:00") |
| `{estimated_end}` | Estimated completion time (e.g., "14:30") |
| `{minutes}` | Remaining minutes as number (e.g., 90) |
| `{minutes}` | Remaining minutes (almost-done) or elapsed minutes since completion (unload reminder) |
| `{error}` | Error message text (only in error notification) |
| `{tub_count}` | Tub clean counter value (only in tub clean notification) |
| `{tub_threshold}` | Tub clean threshold (only in tub clean notification) |
@@ -64,6 +67,18 @@ All message templates support these placeholder variables (use single braces):
- Notification service entity
- (Optional) Power sensor for energy tracking
## Unload Reminder
If `Unload Reminder Delay` is greater than 0, the automation:
1. Records the completion timestamp (`cct`) when the cycle finishes — but **only if** every door sensor in `Unload Reminder Door Sensors` is currently closed (or the list is empty).
2. Sends a reminder N minutes later (where N = delay), then optionally repeats every `Unload Reminder Repeat Interval` minutes up to `Unload Reminder Repeat Count` total.
3. Cancels all pending reminders when:
- A new cycle starts (start handler clears `cct` and `urc`), **or**
- Any of the configured door / lid sensors transitions to `on` (treated as "user has unloaded").
The check runs once per minute via a `time_pattern` trigger. The door sensor list is optional — leave it empty to keep reminders purely time-based.
## Note
Default messages are in Russian for LG ThinQ integration. Customize messages in the "Messages" section for your language.
+196 -2
View File
@@ -65,6 +65,67 @@ blueprint:
selector:
boolean:
# -------------------------------------------------------------------------
# Unload Reminder
# -------------------------------------------------------------------------
# Optional reminder to unload the appliance after the cycle completes.
# Purely time-based: no door/state tracking required.
unload_reminder_group:
name: "Unload Reminder"
collapsed: true
input:
unload_reminder_delay:
name: Unload Reminder Delay (minutes)
description: >
Send a reminder this many minutes after the cycle completes
if the appliance has not been emptied yet.
Set to 0 to disable unload reminders entirely.
default: 0
selector:
number:
min: 0
max: 180
unit_of_measurement: minutes
mode: slider
unload_reminder_repeat_count:
name: Unload Reminder Repeat Count
description: >
Total number of unload reminders to send (including the first).
Set to 1 for a single reminder.
default: 1
selector:
number:
min: 1
max: 10
mode: slider
unload_reminder_repeat_interval:
name: Unload Reminder Repeat Interval (minutes)
description: >
Time between repeated unload reminders.
Only used when Repeat Count > 1.
default: 15
selector:
number:
min: 5
max: 120
unit_of_measurement: minutes
mode: slider
unload_reminder_door_sensors:
name: Unload Reminder Door Sensors (optional)
description: >
Optional list of door / lid binary sensors. When any of them
opens after the cycle completes, pending unload reminders are
cancelled (treated as "user has unloaded").
Leave empty to keep reminders purely time-based.
default: []
selector:
entity:
domain: binary_sensor
multiple: true
# -------------------------------------------------------------------------
# Persistent State Configuration
# -------------------------------------------------------------------------
@@ -143,6 +204,17 @@ blueprint:
text:
multiline: true
message_unload_reminder:
name: "Unload Reminder Message"
description: >
Reminder sent after the cycle completes if the appliance has not
been unloaded within the configured delay.
Variables: `{appliance_name}`, `{minutes}` (elapsed since completion)
default: "🧺 {appliance_name}: прошло {minutes} мин. — пора достать вещи!"
selector:
text:
multiline: true
message_almost_done:
name: "Almost Done Message"
description: >
@@ -389,6 +461,18 @@ trigger:
entity_id: !input power_sensor
id: "power_update"
# Periodic tick for time-based checks (e.g., unload reminder)
- platform: time_pattern
minutes: "/1"
id: "reminder_tick"
# Door / lid opened after completion (cancels unload reminder)
# Note: Uses multiple selector, so empty list means trigger is skipped
- platform: state
entity_id: !input unload_reminder_door_sensors
to: 'on'
id: "door_opened"
# =============================================================================
# CONDITIONS
# =============================================================================
@@ -412,6 +496,8 @@ variables:
state_cycle_start_time: 'cst' # Cycle start timestamp
state_energy_samples: 'esmp' # Energy sample accumulator (Wh)
state_last_sample_time: 'lst' # Last power sample timestamp
state_cycle_completion_time: 'cct' # Cycle completion timestamp (for unload reminder)
state_unload_reminder_count: 'urc' # Number of unload reminders already sent
# ---------------------------------------------------------------------------
# Input Variables
@@ -431,6 +517,10 @@ variables:
show_estimated_end_time: !input show_estimated_end_time
power_sensor: !input power_sensor
energy_cost_per_kwh: !input energy_cost_per_kwh
unload_reminder_delay: !input unload_reminder_delay
unload_reminder_repeat_count: !input unload_reminder_repeat_count
unload_reminder_repeat_interval: !input unload_reminder_repeat_interval
unload_reminder_door_sensors: !input unload_reminder_door_sensors
enable_debug_notifications: !input enable_debug_notifications
# ---------------------------------------------------------------------------
@@ -438,6 +528,7 @@ variables:
# ---------------------------------------------------------------------------
message_start_template: !input message_start
message_completed_template: !input message_completed
message_unload_reminder_template: !input message_unload_reminder
message_almost_done_template: !input message_almost_done
message_preparation_template: !input message_preparation
message_error_template: !input message_error
@@ -619,7 +710,9 @@ action:
state_notification_about_start_sent: true,
state_cycle_start_time: now().isoformat(),
state_energy_samples: 0,
state_last_sample_time: now().isoformat()
state_last_sample_time: now().isoformat(),
state_cycle_completion_time: '',
state_unload_reminder_count: 0
})) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
@@ -686,13 +779,17 @@ action:
entity_id: "{{ automation_state_entity }}"
data:
value: >
{% set any_door_open = (unload_reminder_door_sensors | select('is_state', 'on') | list | length) > 0 %}
{% set arm_reminder = (unload_reminder_delay | int(0)) > 0 and not any_door_open %}
{% set new_automation_state = (automation_state | combine({
state_notification_about_remaining_time_sent: false,
state_notification_about_start_sent: false,
state_notification_about_preparation_sent: false,
state_notification_about_pause_sent: false,
state_was_paused: false,
state_energy_samples: 0
state_energy_samples: 0,
state_cycle_completion_time: (now().isoformat() if arm_reminder else ''),
state_unload_reminder_count: 0
})) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
@@ -973,3 +1070,100 @@ action:
state_last_sample_time: now().isoformat()
})) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# -----------------------------------------------------------------------
# CASE 10: Unload Reminder
# -----------------------------------------------------------------------
# Triggered when: Cycle completion timestamp is recorded and the
# configured delay (plus any repeat intervals) has elapsed without a
# new cycle starting. CASE 1 clears the timestamp on a new cycle.
# Action: Remind the user to unload the appliance.
- conditions:
- condition: template
value_template: >
{%- set cct_val = automation_state.get(state_cycle_completion_time, '') -%}
{%- set count_val = automation_state.get(state_unload_reminder_count, 0) | int(0) -%}
{{ (unload_reminder_delay | int(0)) > 0
and cct_val not in ['', 'unknown', 'unavailable', 'none']
and count_val < (unload_reminder_repeat_count | int(1))
and ((now() - (cct_val | as_datetime)).total_seconds() / 60)
>= ((unload_reminder_delay | int(0)) + count_val * (unload_reminder_repeat_interval | int(0))) }}
sequence:
- variables:
cct_val: "{{ automation_state.get(state_cycle_completion_time, '') }}"
count_val: "{{ automation_state.get(state_unload_reminder_count, 0) | int(0) }}"
new_reminder_count: "{{ (count_val | int(0)) + 1 }}"
elapsed_minutes: >
{{ ((now() - (cct_val | as_datetime)).total_seconds() / 60) | round(0) | int }}
# Render the message template with available variables
message: >
{% set tpl = message_unload_reminder_template %}
{{ tpl | replace('{appliance_name}', appliance_name)
| replace('{minutes}', elapsed_minutes | string) }}
# Send unload reminder notification
- service: notify.send_message
target:
entity_id: !input notify_target
data:
message: "{{ message }}"
# Increment reminder count; clear completion time when last reminder is sent
- service: input_text.set_value
target:
entity_id: "{{ automation_state_entity }}"
data:
value: >
{% set new_automation_state = (automation_state | combine({
state_unload_reminder_count: new_reminder_count | int(0),
state_cycle_completion_time: ('' if (new_reminder_count | int(0)) >= (unload_reminder_repeat_count | int(1)) else cct_val)
})) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Debug notification for unload reminder
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "{appliance_name} - UNLOAD REMINDER"
message: >
Action: UNLOAD REMINDER
Time: {{ now().strftime('%H:%M:%S') }}
Reminder #: {{ new_reminder_count }} of {{ unload_reminder_repeat_count }}
Elapsed: {{ elapsed_minutes }} min
# -----------------------------------------------------------------------
# CASE 11: Door Opened After Completion (cancel unload reminder)
# -----------------------------------------------------------------------
# Triggered when: A configured door / lid sensor opens while a cycle
# completion timestamp is pending. Treated as "user has unloaded".
# Action: Clear cct and urc so no further reminders fire.
- conditions:
- condition: template
value_template: >
{{ trigger.id == 'door_opened'
and automation_state.get(state_cycle_completion_time, '') not in ['', 'unknown', 'unavailable', 'none'] }}
sequence:
- service: input_text.set_value
target:
entity_id: "{{ automation_state_entity }}"
data:
value: >
{% set new_automation_state = (automation_state | combine({
state_cycle_completion_time: '',
state_unload_reminder_count: 0
})) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Debug notification for door-cancelled reminder
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "{appliance_name} - UNLOAD DETECTED"
message: >
Action: UNLOAD REMINDER CANCELLED
Time: {{ now().strftime('%H:%M:%S') }}
Door: {{ trigger.entity_id }}
+2
View File
@@ -14,11 +14,13 @@ A collection of automation blueprints for Home Assistant.
| Home Presence | Determines home presence from multiple signals |
| Immich Album Watcher | Sends notifications when photos are added to Immich albums |
| Motion Light | Smart motion sensor light control |
| Presence Scene Controller | Per-room presence-aware time-of-day scene controller |
| Refrigerator | Monitors refrigerator temperature and triggers express mode |
| Telegram Commands | Responds to Telegram bot commands with callback actions |
| Telegram Question | Sends Telegram messages with inline keyboard buttons |
| Thermostat Controller | Controls thermostat based on schedules, presence, and window sensors |
| Time Of Day Controller | Sets input_select based on time-of-day thresholds |
| Time Of Day Selector | Sets an input_select state when a configured time entity fires |
| Track Abnormal Plug Activity | Monitors power sensors for sustained overconsumption |
| Washing Machine | Sends notifications for washing machine events |
+33 -10
View File
@@ -4,19 +4,42 @@ Controls lights and switches using Zigbee2MQTT button devices with multiple acti
## Features
- Map multiple action IDs to different lights/switches
- Supports light, switch, and input_boolean entities
- Visual feedback via blink indication when pressing already-active light
- Tracks last interacted entity in an input_text helper
- Supports multiple MQTT topics (multiple buttons)
- Map multiple action IDs to different lights/switches by index position
- Supports `light`, `switch`, and `input_boolean` entities
- Visual feedback via blink indication when pressing an already-active light after an idle timeout
- Optionally tracks last interacted entity in an `input_text` helper
- Supports two MQTT topics for two physical buttons
- Optional debug notifications for troubleshooting
## How It Works
1. Receives MQTT messages with action IDs from Zigbee buttons
2. Maps action ID to corresponding entity by index position
3. Toggles the matched entity (light/switch/input_boolean)
4. Optionally blinks light if it's already on (idle timeout feature)
1. Receives MQTT messages from one or two Zigbee buttons.
2. Reads `action` from the payload and looks it up in the configured **Action IDs** list.
3. The matched index is used to pick the corresponding entity from the **Switches** list.
4. Toggles the matched entity (`light` / `switch` / `input_boolean`).
5. For lights, if the light is already on and has been on for longer than the configured idle timeout, the light is briefly blinked instead of toggled, to indicate it is already active.
6. If an `input_text` helper is configured, the matched entity ID is written to it.
## Configuration
| Input | Description |
| --- | --- |
| **MQTT Topic** | Primary topic (e.g. `zigbee2mqtt/my_button1`). |
| **MQTT Topic 2 (Optional)** | Second topic. Leave the placeholder to disable the second trigger. |
| **Action IDs** | Ordered list of action strings emitted by the button (e.g. `single`, `double`, `hold`). |
| **Switches** | Entities matched to **Action IDs** by index — index `0` maps to action `0`, and so on. |
| **Last Interacted Entity Text Helper (optional)** | `input_text` that receives the last toggled entity ID. Leave empty to skip. |
| **Timeout for indicator blink** | Idle seconds after which a press on an already-on light blinks instead of toggling. `0` disables blinking. |
| **Count of blinks** | Number of blink cycles. `0` also disables blinking (every press just toggles). |
| **Interval between blinks** | Delay between turn off / turn on in milliseconds. |
| **Enable Debug Notifications** | When on, posts a persistent notification per trigger and per light-branch decision. |
The number of **Action IDs** should match the number of **Switches**. Out-of-range or unknown actions are ignored safely.
## Debug Mode
Enable **Enable Debug Notifications** to surface the resolved action, target index, target entity, and (for lights) the elapsed-time / blink decision in Home Assistant's persistent notifications panel. Disable when not troubleshooting to avoid notification noise.
## Author
Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
Alexei Dolgolyov (<dolgolyov.alexei@gmail.com>)
+144 -53
View File
@@ -6,10 +6,13 @@
blueprint:
name: "Custom: MQTT Button Control"
description: Control a Zigbee2MQTT device with multiple actions, that allows to toggle lights and switches and store entity identifier of last interacted switch/light.
description: >
Control a Zigbee2MQTT device with multiple actions: toggle lights,
switches and input_booleans, and optionally remember the entity that
was last interacted with in an input_text helper.
domain: automation
input:
mqtt_group:
name: "MQTT"
collapsed: false
@@ -19,7 +22,7 @@ blueprint:
description: The MQTT topic for your Zigbee button (e.g., zigbee2mqtt/my_button1).
selector:
text:
mqtt_topic2:
name: MQTT Topic 2 (Optional)
description: >
@@ -28,7 +31,7 @@ blueprint:
default: "blueprint/disabled/mqtt_button_control"
selector:
text:
devices:
name: "Primary"
collapsed: false
@@ -40,19 +43,19 @@ blueprint:
selector:
text:
multiple: true
switches:
name: Switches
description: "The list of switches to control. Next types are supported: `light`, `switch`, `input_boolean`"
default: []
selector:
entity:
domain:
domain:
- light
- switch
- input_boolean
multiple: true
multiple: true
outputs:
name: "Outputs"
collapsed: false
@@ -64,7 +67,7 @@ blueprint:
selector:
entity:
domain: input_text
common:
name: "Common"
collapsed: false
@@ -78,8 +81,8 @@ blueprint:
min: 0
max: 100
step: 1
unit_of_measurement: "s"
unit_of_measurement: "s"
blink_count:
name: Count of blinks
description: "Count of blinks to indicate active light"
@@ -88,8 +91,8 @@ blueprint:
number:
min: 0
max: 5
step: 1
step: 1
blink_interval:
name: Interval between blinks
description: "Interval between indicator blinks (in ms)"
@@ -98,18 +101,36 @@ blueprint:
number:
min: 0
max: 1000
step: 50
unit_of_measurement: "ms"
step: 50
unit_of_measurement: "ms"
# -------------------------------------------------------------------------
# Debug
# -------------------------------------------------------------------------
debug_group:
name: "Debug"
collapsed: true
input:
enable_debug_notifications:
name: Enable Debug Notifications
description: >
Send persistent notifications for debugging automation behavior.
Shows received action, resolved target entity and blink decision.
default: false
selector:
boolean:
trigger:
- platform: mqtt
topic: !input mqtt_topic
id: button_1
- platform: mqtt
topic: !input mqtt_topic2
enabled: "{{ mqtt_topic2 != '' }}"
id: button_2
enabled: "{{ mqtt_topic2 not in ['', 'blueprint/disabled/mqtt_button_control'] }}"
mode: restart
condition:
- condition: template
value_template: "{{ 'action' in trigger.payload_json }}"
@@ -117,21 +138,59 @@ condition:
action:
- variables:
action_id: "{{ trigger.payload_json.action }}"
switches: !input switches
action_ids: !input action_ids
target_index: >
{% if action_id in action_ids %}
{{ action_ids.index(action_id) }}
{% else %}
-1
{% endif %}
target_entity: "{{ switches[target_index] if target_index != -1 else none }}"
last_interacted_text: !input last_interacted_text
is_debug: false
is_debug: !input enable_debug_notifications
# Resolve action_id → switch index. Whitespace-controlled and coerced
# to int so subscripting `switches[target_index]` is safe.
target_index: >-
{%- if action_id in action_ids -%}
{{ action_ids.index(action_id) }}
{%- else -%}
-1
{%- endif -%}
# Bounds-check the index: action_ids may have more entries than switches.
target_entity: >-
{%- set idx = target_index | int(-1) -%}
{%- if idx >= 0 and idx < (switches | length) -%}
{{ switches[idx] }}
{%- else -%}
{{ none }}
{%- endif -%}
has_last_interacted_text: "{{ last_interacted_text is string and last_interacted_text | trim != '' }}"
# ---------------------------------------------------------------------------
# Debug: trigger received
# ---------------------------------------------------------------------------
- choose:
- conditions:
- condition: template
value_template: "{{ is_debug }}"
sequence:
- service: persistent_notification.create
data:
notification_id: "mqtt_button_control_debug"
title: "MQTT Button Control Debug"
message: >
Trigger: {{ trigger.id }}
Topic: {{ trigger.topic }}
action_id: {{ action_id }}
action_ids: {{ action_ids }}
switches: {{ switches }}
target_index: {{ target_index | int(-1) }}
target_entity: {{ target_entity if target_entity is not none else 'none' }}
has_last_interacted_text: {{ has_last_interacted_text }}
- choose:
- conditions:
- condition: template
@@ -139,12 +198,18 @@ action:
sequence:
- variables:
entity_type: "{{ target_entity.split('.')[0] }}"
- service: input_text.set_value
data:
entity_id: "{{ last_interacted_text }}"
value: "{{ target_entity }}"
# Persist last interacted entity only when a helper is configured.
- choose:
- conditions:
- condition: template
value_template: "{{ has_last_interacted_text }}"
sequence:
- service: input_text.set_value
data:
entity_id: "{{ last_interacted_text }}"
value: "{{ target_entity }}"
- choose:
# Light
- conditions:
@@ -153,14 +218,29 @@ action:
sequence:
- variables:
timeout_for_indication_blink: !input timeout_for_indication_blink
seconds_elapsed: >
{{ (as_timestamp(now()) - as_timestamp(states[target_entity].last_changed)) | int }}
should_blink: "{{ timeout_for_indication_blink != 0 and seconds_elapsed > timeout_for_indication_blink }}"
blink_count: !input blink_count
blink_timeout: !input blink_interval
is_light_on: "{{ is_state(target_entity, 'on') }}"
# Debug
# Inline state access — HA stringifies State objects when
# stored in `variables:`, so `.last_changed` must be read
# within a single template render.
seconds_elapsed: >-
{%- set s = states[target_entity] -%}
{%- if s is not none -%}
{{ (as_timestamp(now()) - as_timestamp(s.last_changed)) | int(0) }}
{%- else -%}
0
{%- endif -%}
# Blink only when both the idle timeout AND a positive
# blink count are configured; otherwise fall through to a
# plain toggle (a 0 count would otherwise run an empty
# repeat and leave the light untouched).
should_blink: >-
{{ timeout_for_indication_blink != 0
and (blink_count | int(0)) > 0
and (seconds_elapsed | int(0)) > timeout_for_indication_blink }}
# Debug: light branch state
- choose:
- conditions:
- condition: template
@@ -168,13 +248,25 @@ action:
sequence:
- service: persistent_notification.create
data:
title: "Debug Info"
notification_id: "mqtt_button_control_debug_light"
title: "MQTT Button Control Debug (light)"
message: >
seconds_elapsed = {{ seconds_elapsed }},
should_blink = {{ should_blink }}
target_entity: {{ target_entity }}
is_light_on: {{ is_light_on }}
seconds_elapsed: {{ seconds_elapsed }}
timeout_for_indication_blink: {{ timeout_for_indication_blink }}
should_blink: {{ should_blink }}
blink_count: {{ blink_count }}
blink_timeout (ms): {{ blink_timeout }}
- choose:
# Blink
# Blink (indicate light is already on after idle timeout)
- conditions:
- condition: template
value_template: "{{ should_blink and is_light_on }}"
@@ -182,7 +274,6 @@ action:
- repeat:
count: "{{ blink_count }}"
sequence:
- service: light.turn_off
target:
entity_id: "{{ target_entity }}"
@@ -190,7 +281,7 @@ action:
transition: 0
- delay:
milliseconds: "{{ blink_timeout }}"
- service: light.turn_on
target:
entity_id: "{{ target_entity }}"
@@ -198,13 +289,13 @@ action:
transition: 0
- delay:
milliseconds: "{{ blink_timeout }}"
# Actually toggle
# Actually toggle
default:
- service: light.toggle
target:
entity_id: "{{ target_entity }}"
# Switch
- conditions:
- condition: template
@@ -213,7 +304,7 @@ action:
- service: switch.toggle
target:
entity_id: "{{ target_entity }}"
# Input Boolean
- conditions:
- condition: template
@@ -221,4 +312,4 @@ action:
sequence:
- service: input_boolean.toggle
target:
entity_id: "{{ target_entity }}"
entity_id: "{{ target_entity }}"
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "2.9.1"
"version": "2.13.0"
}