diff --git a/Common/Presence Scene Controller/README.md b/Common/Presence Scene Controller/README.md new file mode 100644 index 0000000..37af511 --- /dev/null +++ b/Common/Presence Scene Controller/README.md @@ -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 (3–6 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) diff --git a/Common/Presence Scene Controller/blueprint.yaml b/Common/Presence Scene Controller/blueprint.yaml new file mode 100644 index 0000000..84f9405 --- /dev/null +++ b/Common/Presence Scene Controller/blueprint.yaml @@ -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): + # { "": { "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 diff --git a/Common/Time Of Day Selector/README.md b/Common/Time Of Day Selector/README.md new file mode 100644 index 0000000..ebb735b --- /dev/null +++ b/Common/Time Of Day Selector/README.md @@ -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) diff --git a/Common/Time Of Day Selector/blueprint.yaml b/Common/Time Of Day Selector/blueprint.yaml new file mode 100644 index 0000000..0595ea0 --- /dev/null +++ b/Common/Time Of Day Selector/blueprint.yaml @@ -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 }}" diff --git a/README.md b/README.md index cda72fe..ad4fba9 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/manifest.json b/manifest.json index 302d4b9..90bb067 100644 --- a/manifest.json +++ b/manifest.json @@ -1,3 +1,3 @@ { - "version": "2.9.1" + "version": "2.13.0" }