From 6cf6c8fc24e3419c27ab1f468b69b178b1e63b4a Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:54:18 -0400 Subject: [PATCH 1/5] fix: a Calendar Condition sensor follows its calendar to the next event The blueprint matched its condition template against the event that was current when the sensor loaded, because a state-based template entity renders its variables once at startup. On a shared booking calendar the first guest's sensor stayed on through later bookings and the next guest's never turned on until template entities were reloaded. Make it trigger-based, which renders the variables after every trigger: the calendar's state or attributes changing, Home Assistant starting, template entities reloading, and once a minute as a backstop for a user renamed just after their booking starts and for templates that read the time. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 11565057bfa8 --- BLUEPRINTS.md | 12 +- .../lock_code_manager/calendar_condition.yaml | 22 ++- tests/test_blueprint_behavior.py | 180 +++++++++++++++--- 3 files changed, 175 insertions(+), 39 deletions(-) diff --git a/BLUEPRINTS.md b/BLUEPRINTS.md index d10bfba21..533c95910 100644 --- a/BLUEPRINTS.md +++ b/BLUEPRINTS.md @@ -107,12 +107,12 @@ their PIN is active. - Supports any HA calendar integration (local, Google, CalDAV, etc.) - The sensor is unavailable while the calendar entity is `unknown` or `unavailable` -- The condition template and the calendar attributes are evaluated when the - sensor is first loaded and when Home Assistant reloads template entities. - With the default `{{ true }}` the sensor tracks the calendar turning on and - off. A condition that reads event attributes is not re-evaluated when the - calendar moves to a different event: reload template entities after the - calendar's events change +- One shared calendar can gate many users: match each user against the + current event, e.g. `{{ name_state in message }}` +- The sensor re-evaluates whenever the calendar's state or current event + changes, when Home Assistant starts, when template entities reload, and once + a minute. The minute check catches a user renamed just after their booking + starts, and a template that reads the time > **Breaking: `lock_entity_ids` is gone.** It came from the credential used > event entity, which no longer lists the config entry's locks as its event diff --git a/blueprints/template/lock_code_manager/calendar_condition.yaml b/blueprints/template/lock_code_manager/calendar_condition.yaml index e5c7648ad..b92333fee 100644 --- a/blueprints/template/lock_code_manager/calendar_condition.yaml +++ b/blueprints/template/lock_code_manager/calendar_condition.yaml @@ -65,11 +65,13 @@ blueprint: - Allow when the slot is named for a known guest: `{{ name_state == 'Guest' }}` - **Note:** The condition template and calendar attributes are evaluated when the - sensor is first loaded and when Home Assistant reloads template entities. For - the default `{{ true }}`, the sensor dynamically tracks the calendar on/off - state. For conditions that reference event attributes, reload template entities - after calendar event changes to re-evaluate. + **When it updates** + + The sensor re-evaluates whenever the calendar's state or current event + changes, when Home Assistant starts, when template entities reload, and + once a minute. The minute check catches what the calendar can't signal: a + user renamed just after their booking starts, or a template that reads the + time. **Template debugging** @@ -121,6 +123,16 @@ blueprint: selector: template: +triggers: + - trigger: state + entity_id: !input calendar_entity + - trigger: homeassistant + event: start + - trigger: event + event_type: event_template_reloaded + - trigger: time_pattern + minutes: "/1" + variables: config_entry: !input config_entry calendar_entity: !input calendar_entity diff --git a/tests/test_blueprint_behavior.py b/tests/test_blueprint_behavior.py index d451ef50a..747d589ad 100644 --- a/tests/test_blueprint_behavior.py +++ b/tests/test_blueprint_behavior.py @@ -5,8 +5,9 @@ automation runs, and we assert on the side effects via mocked services. """ -from collections.abc import Iterator +from collections.abc import AsyncGenerator, Iterator import contextlib +from datetime import timedelta import pathlib from typing import Any from unittest.mock import patch @@ -21,8 +22,16 @@ from homeassistant.components import automation from homeassistant.components.blueprint import models from homeassistant.components.event import ATTR_EVENT_TYPE -from homeassistant.components.template import config as template_config -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.components.template import ( + DATA_COORDINATORS, + config as template_config, +) +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall, State, callback from homeassistant.helpers import entity_registry as er, template from homeassistant.setup import async_setup_component @@ -47,8 +56,10 @@ SLOT_1_ACTIVE_ENTITY, SLOT_1_ENABLED_ENTITY, SLOT_1_EVENT_ENTITY, + SLOT_1_NAME_ENTITY, SLOT_2_EVENT_ENTITY, ) +from .conftest import async_advance_time BLUEPRINTS_ROOT = pathlib.Path(__file__).resolve().parent.parent / "blueprints" BLUEPRINT_FOLDER = BLUEPRINTS_ROOT / "automation" / "lock_code_manager" @@ -1017,6 +1028,136 @@ async def test_limiter_derives_slot_and_config_entry( # --------------------------------------------------------------------------- # +CALENDAR_CONDITION_ENTITY = "binary_sensor.guest_condition" + + +@pytest.fixture +async def template_teardown(hass: HomeAssistant) -> AsyncGenerator[None]: + """ + Shut down trigger-based template entities after the test. + + Home Assistant detaches a trigger-based template entity's triggers only + when templates reload, not when it stops, so the Calendar Condition's + minute backstop would outlive the test and trip the lingering-timer + check in verify_cleanup. Do what a reload does. + """ + yield + for coordinator in hass.data.pop(DATA_COORDINATORS, None) or (): + await coordinator.async_shutdown() + await hass.async_block_till_done() + + +async def _setup_calendar_condition( + hass: HomeAssistant, + lock_code_manager_config_entry: MockConfigEntry, + calendar_entity: str, + condition_template: str, + slot_number: int = 1, +) -> None: + """Instantiate the shipped Calendar Condition blueprint as one sensor.""" + with patch_blueprint( + CALENDAR_PATH, + BLUEPRINTS_ROOT / "template" / CALENDAR_PATH, + schema=template_config.TEMPLATE_BLUEPRINT_SCHEMA, + ): + assert await async_setup_component( + hass, + "template", + { + "template": { + "name": "Guest Condition", + "use_blueprint": { + "path": CALENDAR_PATH, + "input": { + "config_entry": lock_code_manager_config_entry.entry_id, + "calendar_entity": calendar_entity, + "slot_number": slot_number, + "condition_template": condition_template, + }, + }, + } + }, + ) + await hass.async_block_till_done() + + +async def test_calendar_condition_follows_the_current_event( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, + template_teardown, +) -> None: + """ + One shared calendar gates each guest by the event that is on now. + + A booking calendar stays ``on`` across back-to-back stays and only its + event attributes change, so matching the guest against the event has to + re-render on attribute changes. When the match was frozen at load, the + first guest's sensor stayed on through every later booking and the next + guest's never turned on. + """ + calendar_entity = "calendar.bookings" + hass.states.async_set(calendar_entity, "off", {"message": ""}) + await _setup_calendar_condition( + hass, + lock_code_manager_config_entry, + calendar_entity, + "{{ name_state in message }}", + ) + + for calendar_state, message, expected in ( + ("on", "test1 arriving", "on"), + ("on", "test2 arriving", "off"), + ("on", "test1 back again", "on"), + ("off", "", "off"), + ): + hass.states.async_set(calendar_entity, calendar_state, {"message": message}) + await hass.async_block_till_done() + state = hass.states.get(CALENDAR_CONDITION_ENTITY) + assert state is not None + assert state.state == expected, (calendar_state, message) + + +async def test_calendar_condition_catches_a_rename_within_a_minute( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, + template_teardown, +) -> None: + """ + A user renamed just after their booking starts is matched within a minute. + + Rotating a standing user per booking renames them when the event starts, + which can land after the calendar change has already been evaluated. + Nothing about the calendar changes again until checkout, so without the + minute backstop the guest would be locked out for the whole stay. + """ + calendar_entity = "calendar.bookings" + hass.states.async_set(calendar_entity, "off", {"message": ""}) + await _setup_calendar_condition( + hass, + lock_code_manager_config_entry, + calendar_entity, + "{{ name_state in message }}", + ) + + hass.states.async_set(calendar_entity, "on", {"message": "Sarah Chen"}) + await hass.async_block_till_done() + assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "off" + + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: "Sarah Chen"}, + target={ATTR_ENTITY_ID: SLOT_1_NAME_ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + + await async_advance_time(hass, timedelta(minutes=1)) + assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "on" + + @pytest.mark.parametrize( ("condition_template", "expected_state", "expect_warning"), [ @@ -1030,6 +1171,7 @@ async def test_calendar_condition_fails_quietly_on_the_removed_variable( caplog, mock_lock_config_entry, lock_code_manager_config_entry, + template_teardown, condition_template: str, expected_state: str, expect_warning: bool, @@ -1049,32 +1191,14 @@ async def test_calendar_condition_fails_quietly_on_the_removed_variable( hass.states.async_set(calendar_entity, "on", {"message": "Guest"}) caplog.clear() - with patch_blueprint( - CALENDAR_PATH, - BLUEPRINTS_ROOT / "template" / CALENDAR_PATH, - schema=template_config.TEMPLATE_BLUEPRINT_SCHEMA, - ): - assert await async_setup_component( - hass, - "template", - { - "template": { - "name": "Guest Condition", - "use_blueprint": { - "path": CALENDAR_PATH, - "input": { - "config_entry": lock_code_manager_config_entry.entry_id, - "calendar_entity": calendar_entity, - "slot_number": 1, - "condition_template": condition_template, - }, - }, - } - }, - ) - await hass.async_block_till_done() + await _setup_calendar_condition( + hass, lock_code_manager_config_entry, calendar_entity, condition_template + ) + # A sensor created while Home Assistant is running renders on its first + # trigger; the minute backstop is the one that fires here. + await async_advance_time(hass, timedelta(minutes=1)) - state = hass.states.get("binary_sensor.guest_condition") + state = hass.states.get(CALENDAR_CONDITION_ENTITY) assert state is not None assert state.state == expected_state warned = ( From 093040ea6a74ca861a565798d02ab670841958f3 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:02:47 -0400 Subject: [PATCH 2/5] fix: a Calendar Condition sensor fails closed when its slot number goes empty A trigger-based template entity skips the update when a variable fails to render, so the sensor kept its last state. The documented slot_number example goes empty when the event ends and int raised, leaving a past guest's sensor on. int(0) finds no user instead. Guard the shared-calendar example with name_state and, since name_state is empty when the name entity can't be found and an empty string is in every message. Document that a condition template that raises keeps the sensor where it was, and double the backslash in the regex examples. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 1f9f13140e49 --- BLUEPRINTS.md | 11 ++- .../lock_code_manager/calendar_condition.yaml | 15 ++- tests/test_blueprint_behavior.py | 93 ++++++++++++++++++- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/BLUEPRINTS.md b/BLUEPRINTS.md index 533c95910..5b13999df 100644 --- a/BLUEPRINTS.md +++ b/BLUEPRINTS.md @@ -108,11 +108,18 @@ their PIN is active. - The sensor is unavailable while the calendar entity is `unknown` or `unavailable` - One shared calendar can gate many users: match each user against the - current event, e.g. `{{ name_state in message }}` + current event with `{{ name_state and name_state in message }}`. Keep the + `name_state and`: `name_state` is empty when the user's name entity can't be + found (for example while all the configuration's locks are unavailable), and + an empty string is in every message - The sensor re-evaluates whenever the calendar's state or current event changes, when Home Assistant starts, when template entities reload, and once a minute. The minute check catches a user renamed just after their booking starts, and a template that reads the time +- A condition template that fails to render leaves the sensor where it was: + Home Assistant skips the update instead of turning it off, so a template + that errors once an event ends can leave a PIN active. Give filters a + default for empty values, e.g. `as_datetime(none)` > **Breaking: `lock_entity_ids` is gone.** It came from the credential used > event entity, which no longer lists the config entry's locks as its event @@ -144,7 +151,7 @@ their PIN is active. that resolves to a number from the calendar event's attributes: ```jinja -{{ description | regex_findall('slot (\d+)') | first }} +{{ description | regex_findall('slot (\\d+)') | first }} ``` The condition template can use these variables: diff --git a/blueprints/template/lock_code_manager/calendar_condition.yaml b/blueprints/template/lock_code_manager/calendar_condition.yaml index b92333fee..a6edad8b3 100644 --- a/blueprints/template/lock_code_manager/calendar_condition.yaml +++ b/blueprints/template/lock_code_manager/calendar_condition.yaml @@ -64,6 +64,11 @@ blueprint: - Allow when the slot is named for a known guest: `{{ name_state == 'Guest' }}` + - Match each user against a shared calendar's event: + `{{ name_state and name_state in message }}`. The `name_state and` matters: + it is empty when the user's name entity can't be found, and an empty + string is in every message. + **When it updates** @@ -74,6 +79,12 @@ blueprint: time. + **A condition template that fails to render keeps the sensor where it + was.** Home Assistant skips the update instead of turning the sensor off, + so a template that errors once an event ends can leave a PIN active. Give + filters a default for empty values, e.g. `as_datetime(none)`. + + **Template debugging** To test your condition template, go to Developer Tools → Template and paste: @@ -104,7 +115,7 @@ blueprint: description: > The code slot number. Can be a static number (e.g. `3`) or a Jinja2 template that resolves to a number using calendar event attributes - (e.g. `{{ description | regex_findall('slot (\d+)') | first }}`). + (e.g. `{{ description | regex_findall('slot (\\d+)') | first }}`). selector: template: calendar_entity: @@ -157,7 +168,7 @@ variables: {{ expand(_all_entities) | selectattr('attributes.slot_field', 'defined') | selectattr('attributes.slot_field', 'eq', 'name') - | selectattr('attributes.code_slot', 'eq', slot_number | int) + | selectattr('attributes.code_slot', 'eq', slot_number | int(0)) | map(attribute='entity_id') | first | default('') }} name_state: > {{ states(_name_entity) if _name_entity else '' }} diff --git a/tests/test_blueprint_behavior.py b/tests/test_blueprint_behavior.py index 747d589ad..a02dd4b0a 100644 --- a/tests/test_blueprint_behavior.py +++ b/tests/test_blueprint_behavior.py @@ -1029,6 +1029,10 @@ async def test_limiter_derives_slot_and_config_entry( CALENDAR_CONDITION_ENTITY = "binary_sensor.guest_condition" +# The documented way to match a user against a shared calendar's event. The +# guard matters: ``name_state`` is empty whenever the user's name entity can't +# be found, and an empty string is in every message. +GUEST_MATCH = "{{ name_state and name_state in message }}" @pytest.fixture @@ -1102,7 +1106,7 @@ async def test_calendar_condition_follows_the_current_event( hass, lock_code_manager_config_entry, calendar_entity, - "{{ name_state in message }}", + GUEST_MATCH, ) for calendar_state, message, expected in ( @@ -1138,7 +1142,7 @@ async def test_calendar_condition_catches_a_rename_within_a_minute( hass, lock_code_manager_config_entry, calendar_entity, - "{{ name_state in message }}", + GUEST_MATCH, ) hass.states.async_set(calendar_entity, "on", {"message": "Sarah Chen"}) @@ -1158,6 +1162,91 @@ async def test_calendar_condition_catches_a_rename_within_a_minute( assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "on" +async def test_calendar_condition_turns_off_when_a_parsed_slot_number_goes_empty( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, + template_teardown, +) -> None: + """ + A slot number read out of the event doesn't hold the sensor on after it. + + The blueprint documents reading the slot number from the event + description. When the event ends the description is empty, and a + variable that fails to render skips the update entirely, which leaves a + trigger-based sensor wherever it was: on, for a guest whose booking is + over. + """ + calendar_entity = "calendar.bookings" + hass.states.async_set(calendar_entity, "off", {"description": ""}) + with patch_blueprint( + CALENDAR_PATH, + BLUEPRINTS_ROOT / "template" / CALENDAR_PATH, + schema=template_config.TEMPLATE_BLUEPRINT_SCHEMA, + ): + assert await async_setup_component( + hass, + "template", + { + "template": { + "name": "Guest Condition", + "use_blueprint": { + "path": CALENDAR_PATH, + "input": { + "config_entry": lock_code_manager_config_entry.entry_id, + "calendar_entity": calendar_entity, + "slot_number": ( + "{{ description | regex_findall('slot (\\\\d+)')" + " | first | default('') }}" + ), + "condition_template": GUEST_MATCH, + }, + }, + } + }, + ) + await hass.async_block_till_done() + + hass.states.async_set( + calendar_entity, "on", {"message": "test1", "description": "slot 1"} + ) + await hass.async_block_till_done() + assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "on" + + hass.states.async_set(calendar_entity, "off", {"message": "", "description": ""}) + await hass.async_block_till_done() + assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "off" + + +async def test_calendar_condition_does_not_match_a_user_it_cannot_find( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, + template_teardown, +) -> None: + """ + A slot with no name entity matches no event, rather than every event. + + ``name_state`` is empty when the user's name entity can't be found: no + such slot, or the entity is unavailable and so has no attributes. An + empty string is in every message, so an unguarded match would turn the + sensor on for any booking at all. + """ + calendar_entity = "calendar.bookings" + hass.states.async_set(calendar_entity, "off", {"message": ""}) + await _setup_calendar_condition( + hass, + lock_code_manager_config_entry, + calendar_entity, + GUEST_MATCH, + slot_number=99, + ) + + hass.states.async_set(calendar_entity, "on", {"message": "Somebody else"}) + await hass.async_block_till_done() + assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "off" + + @pytest.mark.parametrize( ("condition_template", "expected_state", "expect_warning"), [ From b4795d1ee98196d50640ec6af6f2ba331c8c3d6f Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:17:14 -0400 Subject: [PATCH 3/5] fix: a Calendar Condition sensor matches its user live, with no timer Replace the free-form condition template with a Match choice (any event, the user's name in the title or description, or "slot N" in the description) and optional Match text, evaluated in the state template. A state-based template entity tracks everything its state template reads, so the sensor follows the calendar and a rename at once, and a render error makes it unavailable, which leaves the PIN inactive. Match defaults to "not set" rather than being required: a blueprint instance missing an input raises MissingInput, which the template integration doesn't catch, so it would stop every template entity from loading. A sensor from before Match that never set a condition template keeps working as any event; one that set its own is unavailable until it has a Match. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: cd13dbe43947 --- BLUEPRINTS.md | 105 ++---- .../lock_code_manager/calendar_condition.yaml | 236 ++++++------- tests/test_blueprint_behavior.py | 311 ++++++++---------- 3 files changed, 282 insertions(+), 370 deletions(-) diff --git a/BLUEPRINTS.md b/BLUEPRINTS.md index 5b13999df..627eee822 100644 --- a/BLUEPRINTS.md +++ b/BLUEPRINTS.md @@ -98,86 +98,53 @@ To set it up: ### Calendar Condition -Creates a template binary sensor that turns ON when a calendar -event is active and an optional condition template evaluates to -true. Assign the sensor as a user's condition entity to control when -their PIN is active. - -- Filter by event title, description, or location using Jinja2 templates -- Supports any HA calendar integration (local, Google, CalDAV, etc.) -- The sensor is unavailable while the calendar entity is `unknown` or - `unavailable` -- One shared calendar can gate many users: match each user against the - current event with `{{ name_state and name_state in message }}`. Keep the - `name_state and`: `name_state` is empty when the user's name entity can't be - found (for example while all the configuration's locks are unavailable), and - an empty string is in every message -- The sensor re-evaluates whenever the calendar's state or current event - changes, when Home Assistant starts, when template entities reload, and once - a minute. The minute check catches a user renamed just after their booking - starts, and a template that reads the time -- A condition template that fails to render leaves the sensor where it was: - Home Assistant skips the update instead of turning it off, so a template - that errors once an event ends can leave a PIN active. Give filters a - default for empty values, e.g. `as_datetime(none)` - -> **Breaking: `lock_entity_ids` is gone.** It came from the credential used -> event entity, which no longer lists the config entry's locks as its event -> types, and nothing else exposes a config entry's locks to a template. -> **A condition template that still references it fails quietly.** Home -> Assistant treats an undefined template variable as empty rather than as an -> error, so the binary sensor stays available, reads `off`, and the PIN never -> activates. The one signal is a log line — `Template variable warning: -> 'lock_entity_ids' is undefined when rendering ...` — so check the Home -> Assistant log after upgrading. Nothing can make that louder: the sensor -> cannot tell an undefined variable from a condition that is honestly false. -> There is no replacement and nothing worth stubbing it to. The list was -> identical for every slot in the config entry, so -> `{{ 'lock.front_door' in lock_entity_ids }}` could only ever be constantly -> true or constantly false: a condition entity gates a *user* across every -> lock in the entry, and never one lock. Delete the reference to fix the -> sensor. +Creates a template binary sensor that is ON while a calendar event belongs to +one user. Assign the sensor as that user's condition entity to control when +their PIN is active. One shared calendar can gate many users: create one +sensor per user, each matching that user's events. [![Import Blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Framan325%2Flock_code_manager%2Fblob%2Fmain%2Fblueprints%2Ftemplate%2Flock_code_manager%2Fcalendar_condition.yaml) | Input | Description | Default | | ----- | ----------- | ------- | | Lock Code Manager config entry | Config entry holding the user | Required | -| Slot number | The user's slot number (see [Finding a slot number](#finding-a-slot-number)), as a number or a template | Required | +| Slot number | The user's slot number (see [Finding a slot number](#finding-a-slot-number)) | Required | | Calendar entity | Calendar to monitor | Required | -| Condition template | Jinja2 template that must also be true for the sensor to turn on, in addition to the calendar event being active | `{{ true }}` | - -**Slot number** can be a static number such as `3`, or a Jinja2 template -that resolves to a number from the calendar event's attributes: - -```jinja -{{ description | regex_findall('slot (\\d+)') | first }} -``` - -The condition template can use these variables: - -| Variable | Meaning | -| -------- | ------- | -| `message` | The event's summary (title) | -| `description` | The event's description | -| `location` | The event's location | -| `start_time` | The event's start time | -| `end_time` | The event's end time | -| `all_day` | Whether the event is an all-day event | -| `slot_number` | The resolved slot number | -| `config_entry_title` | The Lock Code Manager config entry's title | -| `name_state` | The current name of the user in that slot, or empty if the slot has no name entity | - -Example condition templates: - -- Allow when the event title contains "Guest": `{{ 'Guest' in message }}` -- Allow when the slot is named for a known guest: `{{ name_state == 'Guest' }}` +| Match | Which events belong to the user (below) | Any event, for sensors from before Match existed | +| Match text | Text the event's title or description must also contain, ignoring case | None | + +| Match | The sensor is on while the current event… | +| ----- | ----------------------------------------- | +| Any event | is any event | +| User's name in the event title | has the user's name in its title | +| User's name in the event description | has the user's name in its description | +| "slot N" in the event description | says `slot 3` in its description, for the user on slot 3 (`slot 12` does not count as `slot 1`) | + +- Name matching ignores case. A user the sensor can't find (no such slot, or + their name entity unavailable while all the configuration's locks are) + matches no event +- The sensor updates as soon as the calendar moves to another event or the + user is renamed +- It is unavailable while the calendar entity is `unknown` or `unavailable`, + and if its template fails to render, so a problem leaves the PIN inactive + rather than active +- Free-form condition templates are not supported. For a rule these choices + don't cover, make your own [template binary + sensor](https://www.home-assistant.io/integrations/template/) and use it as + the condition entity + +> **Breaking: the condition template is gone, replaced by Match and Match +> text.** A sensor set up before this version that never set a condition +> template keeps working as **Any event**. One that set its own template is +> unavailable, so its user's PIN stays inactive, until you choose a Match. +> Re-import the blueprint to get this version: Home Assistant keeps its own +> copy of an imported blueprint. To set it up: 1. Import the blueprint with the button above. -1. Configure it with your Lock Code Manager config entry, the slot number, - the calendar entity, and optionally a condition template. +1. Configure it with your Lock Code Manager config entry, the user's slot + number, the calendar entity, and a Match. 1. Assign the binary sensor it creates as that user's condition entity. ### Date Range Condition diff --git a/blueprints/template/lock_code_manager/calendar_condition.yaml b/blueprints/template/lock_code_manager/calendar_condition.yaml index a6edad8b3..962db0a3e 100644 --- a/blueprints/template/lock_code_manager/calendar_condition.yaml +++ b/blueprints/template/lock_code_manager/calendar_condition.yaml @@ -2,104 +2,39 @@ blueprint: name: "Lock Code Manager: Calendar Condition" description: > - Creates a binary sensor that turns ON when a calendar event is active and - an optional condition template is truthy. Assign the resulting binary sensor - as the condition entity for a Lock Code Manager code slot. + Creates a binary sensor that is ON while a calendar event belongs to one + Lock Code Manager user. Assign the sensor as that user's condition entity. + One shared calendar can gate many users: create one sensor per user, each + matching that user's events. - **Available template variables** + **Match** decides which events belong to the user: - Your condition template can use these variables: + - **Any event** — every event on the calendar. - - `message` — calendar event summary/title + - **User's name in the event title** — the title contains the user's name. - - `description` — calendar event description + - **User's name in the event description** — the description contains it. - - `location` — calendar event location + - **"slot N" in the event description** — the description says `slot 3` + for the user on slot 3. `slot 12` does not count as `slot 1`. - - `start_time` — event start time + Name matching ignores case. **Match text** (optional) narrows any of these + further: the event's title or description must also contain it, again + ignoring case. - - `end_time` — event end time - - `all_day` — whether the event is an all-day event + The sensor updates as soon as the calendar moves to another event or the + user is renamed. It is off when it can't find the user, and unavailable + while the calendar is or if its template fails to render, so a problem + leaves the PIN inactive rather than active. - - `slot_number` — the resolved slot number - - `config_entry_title` — the LCM config entry title - - - `name_state` — the current name of the code slot - - - **Removed: `lock_entity_ids`** - - - This variable no longer exists. It was read off the credential used event - entity, which used to list the config entry's lock entity IDs as its event - types and now records one fixed event type instead. Nothing else in Home - Assistant exposes a config entry's locks to a template, so there is no - replacement. - - - A condition template that still references it fails quietly. Home - Assistant treats an undefined template variable as empty rather than as - an error, so the binary sensor stays available, reads `off`, and the PIN - never activates. The one signal is a line in the Home Assistant log: - `Template variable warning: 'lock_entity_ids' is undefined when rendering - ...`. Check the log after upgrading — nothing else will tell you. - - - Nothing here can make that louder, and there is nothing worth stubbing - the variable to: the sensor cannot tell an undefined variable from a - condition that is honestly false. Nor was the variable worth keeping. The - list was the same for every slot in the config entry, so - `{{ 'lock.front_door' in lock_entity_ids }}` could only ever be constantly - true or constantly false. A Lock Code Manager condition entity gates a - *user* across every lock in the entry; it cannot gate one lock. Remove the - reference to fix the sensor. - - - **Example condition templates** - - - Allow when event title contains "Guest": `{{ 'Guest' in message }}` - - - Allow when the slot is named for a known guest: `{{ name_state == 'Guest' }}` - - - Match each user against a shared calendar's event: - `{{ name_state and name_state in message }}`. The `name_state and` matters: - it is empty when the user's name entity can't be found, and an empty - string is in every message. - - - **When it updates** - - The sensor re-evaluates whenever the calendar's state or current event - changes, when Home Assistant starts, when template entities reload, and - once a minute. The minute check catches what the calendar can't signal: a - user renamed just after their booking starts, or a template that reads the - time. - - - **A condition template that fails to render keeps the sensor where it - was.** Home Assistant skips the update instead of turning the sensor off, - so a template that errors once an event ends can leave a PIN active. Give - filters a default for empty values, e.g. `as_datetime(none)`. - - - **Template debugging** - - To test your condition template, go to Developer Tools → Template and paste: - - ``` - - {% set message = state_attr('calendar.YOUR_CALENDAR', 'message') | default('', true) %} - - {% set description = state_attr('calendar.YOUR_CALENDAR', 'description') | default('', true) %} - - {% set location = state_attr('calendar.YOUR_CALENDAR', 'location') | default('', true) %} - - {{ YOUR_CONDITION_TEMPLATE_HERE }} - - ``` + **Upgrading:** free-form condition templates are no longer supported. A + sensor from an earlier version that never set one keeps working as **Any + event**. A sensor that set one is unavailable, so its user's PIN stays + inactive, until you choose a Match. For a rule these choices don't cover, + make your own template binary sensor and use it as the condition entity. domain: template # yamllint disable-line rule:line-length source_url: https://github.com/raman325/lock_code_manager/blob/main/blueprints/template/lock_code_manager/calendar_condition.yaml @@ -113,69 +48,98 @@ blueprint: slot_number: name: Slot number description: > - The code slot number. Can be a static number (e.g. `3`) or a Jinja2 - template that resolves to a number using calendar event attributes - (e.g. `{{ description | regex_findall('slot (\\d+)') | first }}`). + The slot number of the user this sensor gates. See "Finding a slot + number" in the blueprint documentation. selector: - template: + number: + min: 1 + max: 9999 + mode: box calendar_entity: name: Calendar entity - description: The calendar entity whose events control this condition. + description: The calendar whose events this sensor follows. selector: entity: domain: calendar + match: + name: Match + description: Which events belong to this user. + default: not set + selector: + select: + options: + - label: Any event + value: any + - label: User's name in the event title + value: name_in_title + - label: User's name in the event description + value: name_in_description + - label: '"slot N" in the event description' + value: slot_in_description + match_text: + name: Match text + description: > + Optional. The event's title or description must also contain this text + (ignoring case), e.g. `Guest` or `Cleaning`. + default: "" + selector: + text: condition_template: - name: Condition template + name: Condition template (no longer used) description: > - A Jinja2 template that must evaluate to truthy for the sensor to turn - ON (in addition to the calendar being active). Leave as `{{ true }}` - to activate whenever the calendar event is active. - default: "{{ true }}" + Leave this as `not set` and choose a Match instead. A sensor that still + has a template here is unavailable until it has a Match. + default: not set selector: - template: - -triggers: - - trigger: state - entity_id: !input calendar_entity - - trigger: homeassistant - event: start - - trigger: event - event_type: event_template_reloaded - - trigger: time_pattern - minutes: "/1" + text: variables: config_entry: !input config_entry calendar_entity: !input calendar_entity - config_entry_title: > - {{ config_entry_attr(config_entry, 'title') }} - _all_entities: > - {{ integration_entities(config_entry_title) }} - message: > - {{ state_attr(calendar_entity, 'message') | default('', true) }} - description: > - {{ state_attr(calendar_entity, 'description') | default('', true) }} - location: > - {{ state_attr(calendar_entity, 'location') | default('', true) }} - start_time: > - {{ state_attr(calendar_entity, 'start_time') | default('', true) }} - end_time: > - {{ state_attr(calendar_entity, 'end_time') | default('', true) }} - all_day: > - {{ state_attr(calendar_entity, 'all_day') | default(false, true) }} slot_number: !input slot_number - _name_entity: > - {{ expand(_all_entities) - | selectattr('attributes.slot_field', 'defined') - | selectattr('attributes.slot_field', 'eq', 'name') - | selectattr('attributes.code_slot', 'eq', slot_number | int(0)) - | map(attribute='entity_id') | first | default('') }} - name_state: > - {{ states(_name_entity) if _name_entity else '' }} - _condition_result: !input condition_template + match_text: !input match_text + _chosen_match: !input match + _legacy_condition: !input condition_template + # A sensor from before Match keeps the meaning of its old default, any event, + # unless it set its own condition template: that one can't be honoured, so + # it gets no match at all and the availability template turns it off. + match: >- + {%- if _chosen_match != 'not set' -%}{{ _chosen_match }} + {%- elif _legacy_condition == 'not set' -%}any + {%- else -%}none{%- endif -%} binary_sensor: - state: > - {{ is_state(calendar_entity, 'on') and _condition_result }} + # Everything that can change lives in the state template, so Home Assistant + # tracks it: template entity variables are rendered once, at startup. + state: >- + {%- set title = state_attr(calendar_entity, 'message') + | default('', true) | string | lower -%} + {%- set details = state_attr(calendar_entity, 'description') + | default('', true) | string | lower -%} + {%- set slot = slot_number | int(0) -%} + {%- set name = expand(integration_entities( + config_entry_attr(config_entry, 'title'))) + | selectattr('attributes.slot_field', 'defined') + | selectattr('attributes.slot_field', 'eq', 'name') + | selectattr('attributes.code_slot', 'eq', slot) + | map(attribute='state') | first | default('') | lower -%} + {%- set has_name = name not in ('', 'unknown', 'unavailable') -%} + {%- if match == 'any' -%} + {%- set matched = true -%} + {%- elif match == 'name_in_title' -%} + {%- set matched = has_name and name in title -%} + {%- elif match == 'name_in_description' -%} + {%- set matched = has_name and name in details -%} + {%- elif match == 'slot_in_description' -%} + {%- set matched = slot > 0 + and (slot | string) in details | regex_findall('slot\\s*(\\d+)') -%} + {%- else -%} + {%- set matched = false -%} + {%- endif -%} + {%- set text = match_text | default('', true) | string | lower -%} + {{ is_state(calendar_entity, 'on') and matched + and (text == '' or text in title or text in details) }} availability: > - {{ states(calendar_entity) not in ('unknown', 'unavailable') }} + {{ states(calendar_entity) not in ('unknown', 'unavailable') + and match in ('any', 'name_in_title', 'name_in_description', + 'slot_in_description') }} diff --git a/tests/test_blueprint_behavior.py b/tests/test_blueprint_behavior.py index a02dd4b0a..6438a057d 100644 --- a/tests/test_blueprint_behavior.py +++ b/tests/test_blueprint_behavior.py @@ -5,9 +5,8 @@ automation runs, and we assert on the side effects via mocked services. """ -from collections.abc import AsyncGenerator, Iterator +from collections.abc import Iterator import contextlib -from datetime import timedelta import pathlib from typing import Any from unittest.mock import patch @@ -22,10 +21,7 @@ from homeassistant.components import automation from homeassistant.components.blueprint import models from homeassistant.components.event import ATTR_EVENT_TYPE -from homeassistant.components.template import ( - DATA_COORDINATORS, - config as template_config, -) +from homeassistant.components.template import config as template_config from homeassistant.components.text import ( ATTR_VALUE, DOMAIN as TEXT_DOMAIN, @@ -59,7 +55,6 @@ SLOT_1_NAME_ENTITY, SLOT_2_EVENT_ENTITY, ) -from .conftest import async_advance_time BLUEPRINTS_ROOT = pathlib.Path(__file__).resolve().parent.parent / "blueprints" BLUEPRINT_FOLDER = BLUEPRINTS_ROOT / "automation" / "lock_code_manager" @@ -1029,34 +1024,13 @@ async def test_limiter_derives_slot_and_config_entry( CALENDAR_CONDITION_ENTITY = "binary_sensor.guest_condition" -# The documented way to match a user against a shared calendar's event. The -# guard matters: ``name_state`` is empty whenever the user's name entity can't -# be found, and an empty string is in every message. -GUEST_MATCH = "{{ name_state and name_state in message }}" - - -@pytest.fixture -async def template_teardown(hass: HomeAssistant) -> AsyncGenerator[None]: - """ - Shut down trigger-based template entities after the test. - - Home Assistant detaches a trigger-based template entity's triggers only - when templates reload, not when it stops, so the Calendar Condition's - minute backstop would outlive the test and trip the lingering-timer - check in verify_cleanup. Do what a reload does. - """ - yield - for coordinator in hass.data.pop(DATA_COORDINATORS, None) or (): - await coordinator.async_shutdown() - await hass.async_block_till_done() async def _setup_calendar_condition( hass: HomeAssistant, lock_code_manager_config_entry: MockConfigEntry, calendar_entity: str, - condition_template: str, - slot_number: int = 1, + **inputs: Any, ) -> None: """Instantiate the shipped Calendar Condition blueprint as one sensor.""" with patch_blueprint( @@ -1075,8 +1049,8 @@ async def _setup_calendar_condition( "input": { "config_entry": lock_code_manager_config_entry.entry_id, "calendar_entity": calendar_entity, - "slot_number": slot_number, - "condition_template": condition_template, + "slot_number": 1, + **inputs, }, }, } @@ -1085,69 +1059,123 @@ async def _setup_calendar_condition( await hass.async_block_till_done() +async def _calendar_at( + hass: HomeAssistant, state: str, message: str = "", description: str = "" +) -> str: + """Move the calendar to an event and return the sensor's state.""" + hass.states.async_set( + "calendar.bookings", state, {"message": message, "description": description} + ) + await hass.async_block_till_done() + sensor = hass.states.get(CALENDAR_CONDITION_ENTITY) + assert sensor is not None + return sensor.state + + +@pytest.mark.parametrize( + ("inputs", "event", "expected"), + [ + ({"match": "any"}, ("on", "Anything", ""), "on"), + ({"match": "any"}, ("off", "", ""), "off"), + ({"match": "name_in_title"}, ("on", "TEST1 arriving", ""), "on"), + ({"match": "name_in_title"}, ("on", "test2 arriving", ""), "off"), + ({"match": "name_in_title"}, ("on", "Stay", "test1"), "off"), + ({"match": "name_in_description"}, ("on", "Stay", "Guest: Test1"), "on"), + ({"match": "name_in_description"}, ("on", "test1", "Guest: test2"), "off"), + ({"match": "slot_in_description"}, ("on", "Stay", "Slot 1, two nights"), "on"), + ({"match": "slot_in_description"}, ("on", "Stay", "slot 12"), "off"), + ({"match": "slot_in_description"}, ("on", "slot 1", "slot 2"), "off"), + ({"match": "any", "match_text": "cleaning"}, ("on", "Cleaning", ""), "on"), + ({"match": "any", "match_text": "cleaning"}, ("on", "Party", "cleaning"), "on"), + ({"match": "any", "match_text": "cleaning"}, ("on", "Party", ""), "off"), + ( + {"match": "name_in_title", "match_text": "guest"}, + ("on", "test1 (owner)", ""), + "off", + ), + ], + ids=[ + "any-event", + "any-no-event", + "name-in-title-ignores-case", + "name-in-title-other-user", + "name-in-title-not-description", + "name-in-description", + "name-in-description-not-title", + "slot-in-description", + "slot-12-is-not-slot-1", + "slot-in-description-not-title", + "match-text-in-title", + "match-text-in-description", + "match-text-missing", + "match-text-narrows-a-name-match", + ], +) +async def test_calendar_condition_matches_the_event_to_the_user( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, + inputs: dict[str, str], + event: tuple[str, str, str], + expected: str, +) -> None: + """Each Match choice, and Match text, against events for slot 1 (test1).""" + hass.states.async_set("calendar.bookings", "off", {}) + await _setup_calendar_condition( + hass, lock_code_manager_config_entry, "calendar.bookings", **inputs + ) + + assert await _calendar_at(hass, *event) == expected + + async def test_calendar_condition_follows_the_current_event( hass: HomeAssistant, mock_lock_config_entry, lock_code_manager_config_entry, - template_teardown, ) -> None: """ One shared calendar gates each guest by the event that is on now. A booking calendar stays ``on`` across back-to-back stays and only its - event attributes change, so matching the guest against the event has to - re-render on attribute changes. When the match was frozen at load, the - first guest's sensor stayed on through every later booking and the next + event attributes change. When the match was frozen at load, the first + guest's sensor stayed on through every later booking and the next guest's never turned on. """ - calendar_entity = "calendar.bookings" - hass.states.async_set(calendar_entity, "off", {"message": ""}) + hass.states.async_set("calendar.bookings", "off", {}) await _setup_calendar_condition( hass, lock_code_manager_config_entry, - calendar_entity, - GUEST_MATCH, + "calendar.bookings", + match="name_in_title", ) - for calendar_state, message, expected in ( - ("on", "test1 arriving", "on"), - ("on", "test2 arriving", "off"), - ("on", "test1 back again", "on"), - ("off", "", "off"), - ): - hass.states.async_set(calendar_entity, calendar_state, {"message": message}) - await hass.async_block_till_done() - state = hass.states.get(CALENDAR_CONDITION_ENTITY) - assert state is not None - assert state.state == expected, (calendar_state, message) + assert await _calendar_at(hass, "on", "test1 arriving") == "on" + assert await _calendar_at(hass, "on", "test2 arriving") == "off" + assert await _calendar_at(hass, "on", "test1 back again") == "on" + assert await _calendar_at(hass, "off") == "off" -async def test_calendar_condition_catches_a_rename_within_a_minute( +async def test_calendar_condition_follows_a_rename( hass: HomeAssistant, mock_lock_config_entry, lock_code_manager_config_entry, - template_teardown, ) -> None: """ - A user renamed just after their booking starts is matched within a minute. + A user renamed after their booking starts is matched at once. Rotating a standing user per booking renames them when the event starts, - which can land after the calendar change has already been evaluated. - Nothing about the calendar changes again until checkout, so without the - minute backstop the guest would be locked out for the whole stay. + which can land after the calendar change. Nothing about the calendar + changes again until checkout, so the sensor has to follow the user's name + entity itself. """ - calendar_entity = "calendar.bookings" - hass.states.async_set(calendar_entity, "off", {"message": ""}) + hass.states.async_set("calendar.bookings", "off", {}) await _setup_calendar_condition( hass, lock_code_manager_config_entry, - calendar_entity, - GUEST_MATCH, + "calendar.bookings", + match="name_in_title", ) - - hass.states.async_set(calendar_entity, "on", {"message": "Sarah Chen"}) - await hass.async_block_till_done() - assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "off" + assert await _calendar_at(hass, "on", "Sarah Chen") == "off" await hass.services.async_call( TEXT_DOMAIN, @@ -1158,149 +1186,102 @@ async def test_calendar_condition_catches_a_rename_within_a_minute( ) await hass.async_block_till_done() - await async_advance_time(hass, timedelta(minutes=1)) assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "on" -async def test_calendar_condition_turns_off_when_a_parsed_slot_number_goes_empty( +async def test_calendar_condition_does_not_match_a_user_it_cannot_find( hass: HomeAssistant, mock_lock_config_entry, lock_code_manager_config_entry, - template_teardown, ) -> None: """ - A slot number read out of the event doesn't hold the sensor on after it. + A slot with no name entity matches no event, rather than every event. - The blueprint documents reading the slot number from the event - description. When the event ends the description is empty, and a - variable that fails to render skips the update entirely, which leaves a - trigger-based sensor wherever it was: on, for a guest whose booking is - over. + An empty name is in every title, so a name match against a user that + can't be found (no such slot, or the name entity unavailable and so + without attributes) would otherwise turn the sensor on for any booking. """ - calendar_entity = "calendar.bookings" - hass.states.async_set(calendar_entity, "off", {"description": ""}) - with patch_blueprint( - CALENDAR_PATH, - BLUEPRINTS_ROOT / "template" / CALENDAR_PATH, - schema=template_config.TEMPLATE_BLUEPRINT_SCHEMA, - ): - assert await async_setup_component( - hass, - "template", - { - "template": { - "name": "Guest Condition", - "use_blueprint": { - "path": CALENDAR_PATH, - "input": { - "config_entry": lock_code_manager_config_entry.entry_id, - "calendar_entity": calendar_entity, - "slot_number": ( - "{{ description | regex_findall('slot (\\\\d+)')" - " | first | default('') }}" - ), - "condition_template": GUEST_MATCH, - }, - }, - } - }, - ) - await hass.async_block_till_done() - - hass.states.async_set( - calendar_entity, "on", {"message": "test1", "description": "slot 1"} + hass.states.async_set("calendar.bookings", "off", {}) + await _setup_calendar_condition( + hass, + lock_code_manager_config_entry, + "calendar.bookings", + match="name_in_title", + slot_number=99, ) - await hass.async_block_till_done() - assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "on" - hass.states.async_set(calendar_entity, "off", {"message": "", "description": ""}) - await hass.async_block_till_done() - assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "off" + assert await _calendar_at(hass, "on", "Somebody else") == "off" -async def test_calendar_condition_does_not_match_a_user_it_cannot_find( +async def test_calendar_condition_is_unavailable_with_its_calendar( hass: HomeAssistant, mock_lock_config_entry, lock_code_manager_config_entry, - template_teardown, ) -> None: - """ - A slot with no name entity matches no event, rather than every event. - - ``name_state`` is empty when the user's name entity can't be found: no - such slot, or the entity is unavailable and so has no attributes. An - empty string is in every message, so an unguarded match would turn the - sensor on for any booking at all. - """ - calendar_entity = "calendar.bookings" - hass.states.async_set(calendar_entity, "off", {"message": ""}) + """An unavailable calendar makes the sensor unavailable, which LCM treats as inactive.""" + hass.states.async_set("calendar.bookings", "off", {}) await _setup_calendar_condition( - hass, - lock_code_manager_config_entry, - calendar_entity, - GUEST_MATCH, - slot_number=99, + hass, lock_code_manager_config_entry, "calendar.bookings", match="any" ) - hass.states.async_set(calendar_entity, "on", {"message": "Somebody else"}) - await hass.async_block_till_done() - assert hass.states.get(CALENDAR_CONDITION_ENTITY).state == "off" + assert await _calendar_at(hass, STATE_UNAVAILABLE) == STATE_UNAVAILABLE @pytest.mark.parametrize( - ("condition_template", "expected_state", "expect_warning"), + ("legacy_inputs", "expected"), [ - ("{{ true }}", "on", False), - ("{{ 'lock.front_door' in lock_entity_ids }}", "off", True), + ({}, "on"), + ({"condition_template": "{{ 'Guest' in message }}"}, STATE_UNAVAILABLE), + ({"condition_template": "{{ true }}"}, STATE_UNAVAILABLE), ], - ids=["valid_template", "removed_lock_entity_ids"], + ids=["never-set-a-condition", "custom-condition", "explicit-default-condition"], ) -async def test_calendar_condition_fails_quietly_on_the_removed_variable( +async def test_calendar_condition_from_an_earlier_version( hass: HomeAssistant, - caplog, mock_lock_config_entry, lock_code_manager_config_entry, - template_teardown, - condition_template: str, - expected_state: str, - expect_warning: bool, + legacy_inputs: dict[str, str], + expected: str, ) -> None: """ - A template still naming the removed ``lock_entity_ids`` reads ``off``. - - This is the claim the blueprint's description and BLUEPRINTS.md make to - anybody upgrading, so it is pinned rather than reasoned about: Home - Assistant renders an undefined template variable as empty instead of - raising, which leaves the sensor available, reading ``off``, and the PIN - it gates permanently inactive. The only signal is a template variable - warning in the log. The valid-template case is what makes the ``off`` - mean something -- the same calendar, the same setup, reading ``on``. + A sensor set up before Match existed keeps working or fails closed. + + One that never set a condition template had "any event" and keeps it. + One that set its own template is unavailable, and so inactive, until it + has a Match: honouring the template live isn't possible, and treating it + as "any event" would turn a filtered sensor on during every event. Its + stored template can't be read back to tell ``{{ true }}`` from a real + filter, so an explicit default counts as set too. """ - calendar_entity = "calendar.guest_stay" - hass.states.async_set(calendar_entity, "on", {"message": "Guest"}) - caplog.clear() - + hass.states.async_set("calendar.bookings", "off", {}) await _setup_calendar_condition( - hass, lock_code_manager_config_entry, calendar_entity, condition_template + hass, lock_code_manager_config_entry, "calendar.bookings", **legacy_inputs ) - # A sensor created while Home Assistant is running renders on its first - # trigger; the minute backstop is the one that fires here. - await async_advance_time(hass, timedelta(minutes=1)) - state = hass.states.get(CALENDAR_CONDITION_ENTITY) - assert state is not None - assert state.state == expected_state - warned = ( - "Template variable warning" in caplog.text - and "'lock_entity_ids' is undefined" in caplog.text + assert await _calendar_at(hass, "on", "Anything") == expected + + +async def test_calendar_condition_with_a_match_ignores_an_old_condition( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +) -> None: + """Choosing a Match brings a sensor with an old condition template back.""" + hass.states.async_set("calendar.bookings", "off", {}) + await _setup_calendar_condition( + hass, + lock_code_manager_config_entry, + "calendar.bookings", + match="name_in_title", + condition_template="{{ 'Guest' in message }}", ) - assert warned is expect_warning + + assert await _calendar_at(hass, "on", "test1") == "on" @pytest.mark.parametrize( ("blueprint", "variable", "slot_field"), [ - ("template/lock_code_manager/calendar_condition.yaml", "_name_entity", "name"), ( "automation/lock_code_manager/calendar_pin_setter.yaml", "pin_entity", From 66e48c22af42500b0a68b79f70c2e47d68fed7df Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:24:04 -0400 Subject: [PATCH 4/5] fix: Calendar Condition matches names as whole words A plain substring match turned one user's sensor on for another user's event: test1 during test10's, Al during Alice's. Normalise punctuation and spacing on both sides and match the name between word boundaries. Say what a render error does: the sensor goes unknown, not unavailable, because the availability template takes precedence. Update the two BLUEPRINTS.md passages that still described a templated slot number for the Calendar Condition. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 3d6a10be98e9 --- BLUEPRINTS.md | 20 ++++--- .../lock_code_manager/calendar_condition.yaml | 22 ++++--- tests/test_blueprint_behavior.py | 59 ++++++++++++++++++- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/BLUEPRINTS.md b/BLUEPRINTS.md index 627eee822..1f391a5ca 100644 --- a/BLUEPRINTS.md +++ b/BLUEPRINTS.md @@ -120,14 +120,15 @@ sensor per user, each matching that user's events. | User's name in the event description | has the user's name in its description | | "slot N" in the event description | says `slot 3` in its description, for the user on slot 3 (`slot 12` does not count as `slot 1`) | -- Name matching ignores case. A user the sensor can't find (no such slot, or +- Names match as whole words and ignore case, so `Al` doesn't match + `Alice`. A user the sensor can't find (no such slot, or their name entity unavailable while all the configuration's locks are) matches no event - The sensor updates as soon as the calendar moves to another event or the user is renamed - It is unavailable while the calendar entity is `unknown` or `unavailable`, - and if its template fails to render, so a problem leaves the PIN inactive - rather than active + and unknown if its template fails to render. Only `on` makes a PIN active, + so a problem leaves the PIN inactive rather than active - Free-form condition templates are not supported. For a rule these choices don't cover, make your own [template binary sensor](https://www.home-assistant.io/integrations/template/) and use it as @@ -208,8 +209,8 @@ calendars. | Clear PIN when event ends | Clear the PIN when the calendar event ends | `true` | | Notification service (optional) | A `notify` entity, messaged when the PIN is set or cleared | None | -**Slot number** takes a static number or a template, the same way as in -[Calendar Condition](#calendar-condition). +**Slot number** takes a static number, or a template that reads it from the +event, e.g. `{{ description | regex_findall('slot (\\d+)') | first }}`. The PIN template and the slot number template can use these variables: @@ -412,10 +413,11 @@ To set it up: Lock Code Manager assigns each user a slot number and manages it for you, so the configuration editor and the dashboard cards never show -it. The [Calendar Condition](#calendar-condition) and [Calendar PIN -Setter](#calendar-pin-setter) blueprints still take one, because the -number can come from a template over the calendar event. Every action, -and every other blueprint, addresses the person by name instead. +it. The [Calendar Condition](#calendar-condition) blueprint takes one to +say which user its sensor gates, and the [Calendar PIN +Setter](#calendar-pin-setter) takes one because the number can come from a +template over the calendar event. Every action, and every other blueprint, +addresses the person by name instead. To find the number for a user, open any of that user's entities and look at its `code_slot` attribute. In a template, for a user named Raman diff --git a/blueprints/template/lock_code_manager/calendar_condition.yaml b/blueprints/template/lock_code_manager/calendar_condition.yaml index 962db0a3e..de1abd8b4 100644 --- a/blueprints/template/lock_code_manager/calendar_condition.yaml +++ b/blueprints/template/lock_code_manager/calendar_condition.yaml @@ -19,14 +19,15 @@ blueprint: - **"slot N" in the event description** — the description says `slot 3` for the user on slot 3. `slot 12` does not count as `slot 1`. - Name matching ignores case. **Match text** (optional) narrows any of these + Names match as whole words and ignore case, so `Al` doesn't match `Alice`. + **Match text** (optional) narrows any of these further: the event's title or description must also contain it, again ignoring case. The sensor updates as soon as the calendar moves to another event or the - user is renamed. It is off when it can't find the user, and unavailable - while the calendar is or if its template fails to render, so a problem + user is renamed. It is off when it can't find the user, unavailable while + the calendar is, and unknown if its template fails to render, so a problem leaves the PIN inactive rather than active. @@ -102,7 +103,8 @@ variables: _legacy_condition: !input condition_template # A sensor from before Match keeps the meaning of its old default, any event, # unless it set its own condition template: that one can't be honoured, so - # it gets no match at all and the availability template turns it off. + # it gets no match at all, and the availability template makes it + # unavailable. match: >- {%- if _chosen_match != 'not set' -%}{{ _chosen_match }} {%- elif _legacy_condition == 'not set' -%}any @@ -123,13 +125,19 @@ binary_sensor: | selectattr('attributes.slot_field', 'eq', 'name') | selectattr('attributes.code_slot', 'eq', slot) | map(attribute='state') | first | default('') | lower -%} - {%- set has_name = name not in ('', 'unknown', 'unavailable') -%} + {#- Names match as whole words: punctuation and spacing become single + spaces on both sides, so "Al" doesn't match "Alice". -#} + {%- set words = ' ' ~ (name | regex_replace('\\W+', ' ') | trim) ~ ' ' -%} + {%- set has_name = name not in ('unknown', 'unavailable') + and words | trim != '' -%} + {%- set title_words = ' ' ~ (title | regex_replace('\\W+', ' ')) ~ ' ' -%} + {%- set details_words = ' ' ~ (details | regex_replace('\\W+', ' ')) ~ ' ' -%} {%- if match == 'any' -%} {%- set matched = true -%} {%- elif match == 'name_in_title' -%} - {%- set matched = has_name and name in title -%} + {%- set matched = has_name and words in title_words -%} {%- elif match == 'name_in_description' -%} - {%- set matched = has_name and name in details -%} + {%- set matched = has_name and words in details_words -%} {%- elif match == 'slot_in_description' -%} {%- set matched = slot > 0 and (slot | string) in details | regex_findall('slot\\s*(\\d+)') -%} diff --git a/tests/test_blueprint_behavior.py b/tests/test_blueprint_behavior.py index 6438a057d..47a632023 100644 --- a/tests/test_blueprint_behavior.py +++ b/tests/test_blueprint_behavior.py @@ -27,7 +27,12 @@ DOMAIN as TEXT_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, ServiceCall, State, callback from homeassistant.helpers import entity_registry as er, template from homeassistant.setup import async_setup_component @@ -1080,6 +1085,9 @@ async def _calendar_at( ({"match": "name_in_title"}, ("on", "TEST1 arriving", ""), "on"), ({"match": "name_in_title"}, ("on", "test2 arriving", ""), "off"), ({"match": "name_in_title"}, ("on", "Stay", "test1"), "off"), + ({"match": "name_in_title"}, ("on", "test10 arriving", ""), "off"), + ({"match": "name_in_title"}, ("on", "Stay: test1, 2 nights", ""), "on"), + ({"match": "name_in_description"}, ("on", "Stay", "guest test10"), "off"), ({"match": "name_in_description"}, ("on", "Stay", "Guest: Test1"), "on"), ({"match": "name_in_description"}, ("on", "test1", "Guest: test2"), "off"), ({"match": "slot_in_description"}, ("on", "Stay", "Slot 1, two nights"), "on"), @@ -1100,6 +1108,9 @@ async def _calendar_at( "name-in-title-ignores-case", "name-in-title-other-user", "name-in-title-not-description", + "test10-is-not-test1", + "name-between-punctuation", + "test10-is-not-test1-in-description", "name-in-description", "name-in-description-not-title", "slot-in-description", @@ -1198,8 +1209,9 @@ async def test_calendar_condition_does_not_match_a_user_it_cannot_find( A slot with no name entity matches no event, rather than every event. An empty name is in every title, so a name match against a user that - can't be found (no such slot, or the name entity unavailable and so - without attributes) would otherwise turn the sensor on for any booking. + can't be found would otherwise turn the sensor on for any booking. An + unavailable name entity ends up here too: Home Assistant drops an + unavailable entity's attributes, so the lookup finds nobody. """ hass.states.async_set("calendar.bookings", "off", {}) await _setup_calendar_condition( @@ -1227,6 +1239,47 @@ async def test_calendar_condition_is_unavailable_with_its_calendar( assert await _calendar_at(hass, STATE_UNAVAILABLE) == STATE_UNAVAILABLE +async def test_calendar_condition_is_not_on_when_its_template_fails( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +) -> None: + """ + A state template that fails to render leaves the sensor ``unknown``. + + Only ``on`` makes a PIN active, so an error has to land anywhere but + there. A config entry that doesn't exist makes ``config_entry_attr`` + raise inside the state template. + """ + hass.states.async_set("calendar.bookings", "off", {}) + with patch_blueprint( + CALENDAR_PATH, + BLUEPRINTS_ROOT / "template" / CALENDAR_PATH, + schema=template_config.TEMPLATE_BLUEPRINT_SCHEMA, + ): + assert await async_setup_component( + hass, + "template", + { + "template": { + "name": "Guest Condition", + "use_blueprint": { + "path": CALENDAR_PATH, + "input": { + "config_entry": 12345, + "calendar_entity": "calendar.bookings", + "slot_number": 1, + "match": "any", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + assert await _calendar_at(hass, "on", "Anything") == STATE_UNKNOWN + + @pytest.mark.parametrize( ("legacy_inputs", "expected"), [ From cc941b2c96ef6fa07412e3e669002af2fed9e589 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:31:50 -0400 Subject: [PATCH 5/5] docs(blueprints): list the Calendar Condition's retired template input The blueprint still declares the condition template input so an old sensor that set one goes unavailable instead of failing to load, so the inputs table documents it and the breaking note no longer says it is gone. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 7300de11f870 --- BLUEPRINTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/BLUEPRINTS.md b/BLUEPRINTS.md index 1f391a5ca..190f37f97 100644 --- a/BLUEPRINTS.md +++ b/BLUEPRINTS.md @@ -112,6 +112,7 @@ sensor per user, each matching that user's events. | Calendar entity | Calendar to monitor | Required | | Match | Which events belong to the user (below) | Any event, for sensors from before Match existed | | Match text | Text the event's title or description must also contain, ignoring case | None | +| Condition template (no longer used) | Leave as `not set` and choose a Match instead. A sensor that still has a template here is unavailable until it has a Match | `not set` | | Match | The sensor is on while the current event… | | ----- | ----------------------------------------- | @@ -134,8 +135,8 @@ sensor per user, each matching that user's events. sensor](https://www.home-assistant.io/integrations/template/) and use it as the condition entity -> **Breaking: the condition template is gone, replaced by Match and Match -> text.** A sensor set up before this version that never set a condition +> **Breaking: the condition template no longer works, replaced by Match and +> Match text.** A sensor set up before this version that never set a condition > template keeps working as **Any event**. One that set its own template is > unavailable, so its user's PIN stays inactive, until you choose a Match. > Re-import the blueprint to get this version: Home Assistant keeps its own