From fe7c04111b3be08311799e251fba64a89b0661e7 Mon Sep 17 00:00:00 2001 From: Lorenzo Mattioli Date: Tue, 25 Aug 2026 21:50:30 +0000 Subject: [PATCH 1/5] feat: expose evaluated scenario state on scenario binary_sensors `expose_entities()` already creates `binary_sensor.supernotify_scenario_` but with `state=STATE_UNKNOWN` hard-coded and never refreshed. - evaluate each scenario's conditions with neutral variables (current occupancy, PRIORITY_MEDIUM - the same basis as enquire_active_scenarios) and publish on/off - refresh every minute (time/date driven scenarios) and on state changes of the entities extracted from the conditions via `condition.async_extract_entities` - scenarios whose conditions reference no entity (priority-only, or triggered through applied_scenarios) stay `unknown`, which is the honest answer for them - new `HomeAssistantAPI.subscribe_interval()` helper so the timer is owned by hass_api and torn down with the other subscriptions - test double: expose `loop` on MockableHomeAssistant so timers can be registered in unit tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HydbFBwYt3HV83xQ4UzdjV --- CHANGELOG.md | 4 + conftest.py | 1 + custom_components/supernotify/hass_api.py | 6 +- custom_components/supernotify/notify.py | 68 ++++++++++++++++- .../components/supernotify/hass_setup_lib.py | 5 ++ .../supernotify/test_scenario_state.py | 76 +++++++++++++++++++ 6 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/components/supernotify/test_scenario_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e56e53887..076869d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 2.0.0 +### Scenario state entities + +- `binary_sensor.supernotify_scenario_` now reports the evaluated state of the scenario (`on`/`off`) instead of a hard-coded `unknown`, refreshed every minute and on state changes of the entities referenced by its conditions. Scenarios whose conditions reference no entity (priority-only or manually applied) stay `unknown`. + ### ConfigFlow - SuperNotify is now set up using the standard HomeAssistant UI ('ConfigFlow') diff --git a/conftest.py b/conftest.py index f3ca54060..d0af7cd2a 100644 --- a/conftest.py +++ b/conftest.py @@ -110,6 +110,7 @@ def mock_hass( hass.data[DATA_MQTT].client.connected = True hass.config_entries._entries = ConfigEntryItems(hass) hass.loop_thread_id = "99999" + hass.loop.time.return_value = 0.0 # timers (async_track_time_interval) do arithmetic on loop.time() return hass diff --git a/custom_components/supernotify/hass_api.py b/custom_components/supernotify/hass_api.py index d5d3c0b5f..83f202e67 100644 --- a/custom_components/supernotify/hass_api.py +++ b/custom_components/supernotify/hass_api.py @@ -9,7 +9,7 @@ from homeassistant.components.person import ATTR_USER_ID from homeassistant.const import CONF_ACTION, CONF_DEVICE_ID from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change +from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change, async_track_time_interval from homeassistant.util import slugify if TYPE_CHECKING: @@ -28,6 +28,7 @@ import socket import threading +from datetime import timedelta from contextlib import contextmanager from typing import TYPE_CHECKING, cast @@ -169,6 +170,9 @@ def subscribe_state(self, entity_ids: str | Iterable[str], callback: Callable) - def subscribe_time(self, hour: int, minute: int, second: int, callback: Callable) -> None: self.unsubscribes.append(async_track_time_change(self._hass, callback, hour=hour, minute=minute, second=second)) + def subscribe_interval(self, seconds: int, callback: Callable) -> None: + self.unsubscribes.append(async_track_time_interval(self._hass, callback, timedelta(seconds=seconds))) + def in_hass_loop(self) -> bool: return self._hass is not None and self._hass.loop_thread_id == threading.get_ident() diff --git a/custom_components/supernotify/notify.py b/custom_components/supernotify/notify.py index a9818fc47..287fd3e69 100644 --- a/custom_components/supernotify/notify.py +++ b/custom_components/supernotify/notify.py @@ -34,6 +34,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue, async_delete_issue from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers import condition from . import DOMAIN, PLATFORMS from .archive import ARCHIVE_PURGE_MIN_INTERVAL, NotificationArchive @@ -550,9 +551,19 @@ async def initialize(self) -> None: await self.context.archive.initialize() await self.context.media_storage.initialize(self.context.hass_api) + self._scenario_cond_entities = self._collect_scenario_condition_entities() self.expose_entities() self.context.hass_api.subscribe_event("mobile_app_notification_action", self.on_mobile_action) self.context.hass_api.subscribe_state(self.exposed_entities, self._entity_state_change_listener) + # Keep the scenario binary_sensors' state current: react to their + # condition entities (immediate) and refresh every minute (time/date + # scenarios and any dependency not captured by entity extraction). + scenario_watch: set[str] = ( + set().union(*self._scenario_cond_entities.values()) if self._scenario_cond_entities else set() + ) + if scenario_watch: + self.context.hass_api.subscribe_state(sorted(scenario_watch), self.async_refresh_scenario_states) + self.context.hass_api.subscribe_interval(60, self.async_refresh_scenario_states) housekeeping_schedule = self.housekeeping.get(CONF_HOUSEKEEPING_TIME) if housekeeping_schedule: @@ -700,6 +711,61 @@ async def _entity_state_change_listener(self, event: Event[EventStateChangedData else: _LOGGER.warning("SUPERNOTIFY entity event with nothing to do:%s", event) + def _collect_scenario_condition_entities(self) -> dict[str, set[str]]: + """Entities referenced by each scenario's conditions. + + A scenario whose conditions reference no Home Assistant entity depends + only on the per-notification variables (notification_priority / + applied_scenarios). Such a scenario is 'transient': it has no meaningful + state between notifications, so it is left as STATE_UNKNOWN. Extraction is + best-effort (templates are opaque); the periodic refresh is the safety net. + """ + mapping: dict[str, set[str]] = {} + for name, scenario in self.context.scenario_registry.scenarios.items(): + ents: set[str] = set() + for cond in scenario.conditions_config or []: + try: + ents |= condition.async_extract_entities(cond) + except Exception: # noqa: BLE001 - best-effort extraction + _LOGGER.debug("SUPERNOTIFY could not extract entities for scenario %s", name) + mapping[name] = ents + return mapping + + def _scenario_state(self, scenario: "Scenario", cvars: "ConditionVariables | None" = None) -> str: + """State to expose for a scenario binary_sensor. + + - no conditions, or conditions with no source entity -> transient/manual + -> STATE_UNKNOWN (state is undefined outside of a notification); + - otherwise ON/OFF from a neutral evaluation (current occupancy, medium + priority), the same basis as enquire_active_scenarios(). + """ + if not scenario.conditions_config: + return STATE_UNKNOWN + if not getattr(self, "_scenario_cond_entities", {}).get(scenario.name): + return STATE_UNKNOWN + if cvars is None: + occupiers = self.context.people_registry.determine_occupancy() + cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None) + return STATE_ON if scenario.evaluate(cvars) else STATE_OFF + + @callback + def async_refresh_scenario_states(self, *_args: Any) -> None: + """Re-evaluate and re-publish the state of every scenario binary_sensor. + + Triggered by the 1-minute timer (time/date scenarios and any dependency + not captured by entity extraction) and by state changes of the scenarios' + condition entities (immediate reactivity). Pure in-memory evaluation over + cached states; no I/O. + """ + occupiers = self.context.people_registry.determine_occupancy() + cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None) + for name, scenario in self.context.scenario_registry.scenarios.items(): + self.context.hass_api.set_state( + f"binary_sensor.{DOMAIN}_scenario_{name}", + self._scenario_state(scenario, cvars), + sanitize(scenario.attributes(include_condition=False)), + ) + def expose_entity( self, entity_name: str, @@ -746,7 +812,7 @@ def expose_entities(self) -> None: for scenario in self.context.scenario_registry.scenarios.values(): self.expose_entity( f"scenario_{scenario.name}", - state=STATE_UNKNOWN, + state=self._scenario_state(scenario), attributes=sanitize(scenario.attributes(include_condition=False)), original_name=f"{scenario.name} Scenario", original_icon="mdi:clipboard-text", diff --git a/tests/components/supernotify/hass_setup_lib.py b/tests/components/supernotify/hass_setup_lib.py index cbc3a871f..73c3dfa91 100644 --- a/tests/components/supernotify/hass_setup_lib.py +++ b/tests/components/supernotify/hass_setup_lib.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio + import json import logging import uuid @@ -140,6 +142,9 @@ class MockableHomeAssistant(HomeAssistant): config: ConfigEntries = Mock(spec=ConfigEntries) # type: ignore services: ServiceRegistry = AsyncMock(spec=ServiceRegistry) bus: EventBus = Mock(spec=EventBus) + # async_track_time_interval schedules on hass.loop, an instance attribute + # not visible to Mock(spec=...): expose it here so timers can be registered + loop: asyncio.AbstractEventLoop = Mock(spec=asyncio.AbstractEventLoop) def load_config(v: str | dict | list | None, return_type: type = dict) -> JSON_TYPE: diff --git a/tests/components/supernotify/test_scenario_state.py b/tests/components/supernotify/test_scenario_state.py new file mode 100644 index 000000000..e332e9061 --- /dev/null +++ b/tests/components/supernotify/test_scenario_state.py @@ -0,0 +1,76 @@ +"""Tests for scenario state exposure (fix: scenarios were stuck at STATE_UNKNOWN). + +Covers the classification logic of `SupernotifyAction._scenario_state`: + * a scenario with no conditions (manual / apply_scenarios only) -> UNKNOWN + * a scenario whose conditions reference no entity (priority-only / transient) + -> UNKNOWN + * a scenario whose conditions reference entities (stateful / hybrid) + -> ON/OFF from a neutral evaluation + +File path in the package: tests/components/supernotify/test_scenario_state.py +The method is called unbound with a mock `self` so the test needs no running HA; +the refresh wiring (timer + state-change tracking) is an integration concern. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN + +from custom_components.supernotify.notify import SupernotifyAction + + +def _evaluate_state(conditions_config, cond_entities, evaluate_result: bool) -> str: + """Call SupernotifyAction._scenario_state with a mock self/scenario.""" + me = MagicMock() + me._scenario_cond_entities = {"s": set(cond_entities)} + me.context.people_registry.determine_occupancy.return_value = {} + + scenario = MagicMock() + scenario.name = "s" + scenario.conditions_config = conditions_config + scenario.evaluate.return_value = evaluate_result + + return SupernotifyAction._scenario_state(me, scenario) + + +def test_manual_scenario_without_conditions_is_unknown() -> None: + """A scenario with no conditions (e.g. emergency, applied explicitly) has no + evaluable state -> UNKNOWN.""" + assert _evaluate_state(None, set(), True) == STATE_UNKNOWN + assert _evaluate_state([], set(), True) == STATE_UNKNOWN + + +def test_transient_scenario_without_entities_is_unknown() -> None: + """A scenario whose conditions reference no entity depends only on the + per-notification priority (critical_panic/high_priority/alexa_low_whisper) + -> transient -> UNKNOWN, not a misleading OFF.""" + assert _evaluate_state([{"condition": "template"}], set(), True) == STATE_UNKNOWN + assert _evaluate_state([{"condition": "template"}], set(), False) == STATE_UNKNOWN + + +def test_stateful_scenario_reflects_evaluation() -> None: + """A scenario with entity-backed conditions exposes ON/OFF from the neutral + evaluation (current occupancy, medium priority).""" + ents = {"input_boolean.notifier_dnd"} + assert _evaluate_state([{"condition": "state"}], ents, True) == STATE_ON + assert _evaluate_state([{"condition": "state"}], ents, False) == STATE_OFF + + +def test_stateful_uses_neutral_condition_variables() -> None: + """When no cvars are passed, the state is computed from a freshly built + neutral ConditionVariables (occupancy queried, medium priority).""" + me = MagicMock() + me._scenario_cond_entities = {"s": {"alarm_control_panel.home_alarm"}} + me.context.people_registry.determine_occupancy.return_value = {} + + scenario = MagicMock() + scenario.name = "s" + scenario.conditions_config = [{"condition": "state"}] + scenario.evaluate.return_value = True + + assert SupernotifyAction._scenario_state(me, scenario) == STATE_ON + # occupancy was queried to build the neutral evaluation context + me.context.people_registry.determine_occupancy.assert_called_once() + scenario.evaluate.assert_called_once() From 85786f1b0727dddca7643a7976451ff308771da0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:26:40 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- custom_components/supernotify/hass_api.py | 2 +- custom_components/supernotify/notify.py | 6 +++--- tests/components/supernotify/hass_setup_lib.py | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/custom_components/supernotify/hass_api.py b/custom_components/supernotify/hass_api.py index 83f202e67..0ae682765 100644 --- a/custom_components/supernotify/hass_api.py +++ b/custom_components/supernotify/hass_api.py @@ -28,8 +28,8 @@ import socket import threading -from datetime import timedelta from contextlib import contextmanager +from datetime import timedelta from typing import TYPE_CHECKING, cast import homeassistant.components.trace diff --git a/custom_components/supernotify/notify.py b/custom_components/supernotify/notify.py index 287fd3e69..5e6991b7f 100644 --- a/custom_components/supernotify/notify.py +++ b/custom_components/supernotify/notify.py @@ -31,10 +31,10 @@ callback, ) from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import condition from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue, async_delete_issue from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.reload import async_setup_reload_service -from homeassistant.helpers import condition from . import DOMAIN, PLATFORMS from .archive import ARCHIVE_PURGE_MIN_INTERVAL, NotificationArchive @@ -726,12 +726,12 @@ def _collect_scenario_condition_entities(self) -> dict[str, set[str]]: for cond in scenario.conditions_config or []: try: ents |= condition.async_extract_entities(cond) - except Exception: # noqa: BLE001 - best-effort extraction + except Exception: _LOGGER.debug("SUPERNOTIFY could not extract entities for scenario %s", name) mapping[name] = ents return mapping - def _scenario_state(self, scenario: "Scenario", cvars: "ConditionVariables | None" = None) -> str: + def _scenario_state(self, scenario: Scenario, cvars: ConditionVariables | None = None) -> str: """State to expose for a scenario binary_sensor. - no conditions, or conditions with no source entity -> transient/manual diff --git a/tests/components/supernotify/hass_setup_lib.py b/tests/components/supernotify/hass_setup_lib.py index 73c3dfa91..957c75e3b 100644 --- a/tests/components/supernotify/hass_setup_lib.py +++ b/tests/components/supernotify/hass_setup_lib.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio - import json import logging import uuid From 6410c6e66cf760109664f74e2d2c081389ebfb99 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:43:06 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- custom_components/supernotify/notify.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/supernotify/notify.py b/custom_components/supernotify/notify.py index fb5e352d0..92aef5c5d 100644 --- a/custom_components/supernotify/notify.py +++ b/custom_components/supernotify/notify.py @@ -32,7 +32,6 @@ ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import condition -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue, async_delete_issue from homeassistant.helpers.json import ExtendedJSONEncoder from . import DOMAIN From 1d530fee60ee2158e3b8dd31ffe50d99755ff20c Mon Sep 17 00:00:00 2001 From: lollo Date: Wed, 2 Sep 2026 22:44:06 +0000 Subject: [PATCH 4/5] feat: targeted scenario refresh and a switch for the whole mechanism Evaluating scenario conditions costs whatever the conditions cost, so: - a state change now re-evaluates only the scenarios that depend on the entity that changed, using the index already collected for the subscriptions, rather than the whole registry on every event - scenario_state.enabled: false subscribes to nothing and starts no timer - scenario_state.refresh_interval tunes the sweep, 0 drops it while keeping the reactive path - expose_state: false keeps an individual expensive scenario out of it Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KmMatiNzpXzZmrwWEtCT3x --- CHANGELOG.md | 6 +- custom_components/supernotify/const.py | 4 + custom_components/supernotify/notify.py | 70 ++++++++++++-- custom_components/supernotify/scenario.py | 3 + custom_components/supernotify/schema.py | 16 ++++ docs/usage/scenarios.md | 35 +++++++ .../supernotify/test_scenario_state.py | 93 +++++++++++++++++++ 7 files changed, 216 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75340843a..6448bc6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,11 @@ Gratitude to [@lollox80](https://github.com/lollox80) for contributing 4 new tra ### Scenario state entities -- `binary_sensor.supernotify_scenario_` now reports the evaluated state of the scenario (`on`/`off`) instead of a hard-coded `unknown`, refreshed every minute and on state changes of the entities referenced by its conditions. Scenarios whose conditions reference no entity (priority-only or manually applied) stay `unknown`. +- `binary_sensor.supernotify_scenario_` now reports the evaluated state of the scenario (`on`/`off`) instead of a hard-coded `unknown`. A state change of an entity a scenario's conditions reference re-evaluates only the scenarios depending on that entity; a periodic sweep covers conditions no entity change announces, such as time windows. Scenarios whose conditions reference no entity (priority-only or manually applied) stay `unknown`. +- Evaluating conditions costs whatever the conditions cost, so the whole mechanism is switchable: + - `scenario_state: {enabled: false}` subscribes to nothing and starts no timer + - `scenario_state: {refresh_interval: }` tunes the sweep, or disables it with `0` while keeping the reactive path + - `expose_state: false` on an individual scenario keeps an expensive one out of it without turning the feature off ### ConfigFlow diff --git a/custom_components/supernotify/const.py b/custom_components/supernotify/const.py index b76bc6ca9..8010ef523 100644 --- a/custom_components/supernotify/const.py +++ b/custom_components/supernotify/const.py @@ -42,6 +42,10 @@ CONF_PRIORITY: Final[str] = "priority" CONF_OCCUPANCY: Final[str] = "occupancy" CONF_SCENARIOS: Final[str] = "scenarios" +CONF_SCENARIO_STATE: Final[str] = "scenario_state" +CONF_REFRESH_INTERVAL: Final[str] = "refresh_interval" +CONF_EXPOSE_STATE: Final[str] = "expose_state" +SCENARIO_STATE_REFRESH_DEFAULT: Final[int] = 60 CONF_MANUFACTURER: Final[str] = "manufacturer" CONF_CLASS: Final[str] = "class" diff --git a/custom_components/supernotify/notify.py b/custom_components/supernotify/notify.py index 92aef5c5d..33c6343d4 100644 --- a/custom_components/supernotify/notify.py +++ b/custom_components/supernotify/notify.py @@ -20,6 +20,7 @@ STATE_UNKNOWN, EntityCategory, Platform, + CONF_ENABLED, ) from homeassistant.core import ( Event, @@ -38,6 +39,9 @@ from .archive import ARCHIVE_PURGE_MIN_INTERVAL, NotificationArchive from .common import DupeChecker, sanitize from .const import ( + CONF_REFRESH_INTERVAL, + CONF_SCENARIO_STATE, + SCENARIO_STATE_REFRESH_DEFAULT, ATTR_ACTION, ATTR_DATA, CONF_ACTION_GROUPS, @@ -154,6 +158,7 @@ def build_supernotify_action(hass: HomeAssistant, config: ConfigType) -> Superno cameras=config[CONF_CAMERAS], dupe_check=config[CONF_DUPE_CHECK], snooze=config[CONF_SNOOZE], + scenario_state=config.get(CONF_SCENARIO_STATE), ) @@ -440,11 +445,13 @@ def __init__( cameras: list[dict[str, Any]] | None = None, dupe_check: dict[str, Any] | None = None, snooze: dict[str, Any] | None = None, + scenario_state: dict[str, Any] | None = None, ) -> None: """Initialize the service.""" self.last_notification: Notification | None = None self.failures: int = 0 self.housekeeping: dict[str, Any] = housekeeping or {} + self.scenario_state_config: dict[str, Any] = scenario_state or {} self.sent: int = 0 hass_api = HomeAssistantAPI(hass) @@ -484,18 +491,23 @@ async def initialize(self) -> None: await self.context.media_storage.initialize(self.context.hass_api) self._scenario_cond_entities = self._collect_scenario_condition_entities() + self._scenario_by_entity = self._index_scenarios_by_entity() self.expose_entities() self.context.hass_api.subscribe_event("mobile_app_notification_action", self.on_mobile_action) self.context.hass_api.subscribe_state(self.exposed_entities, self._entity_state_change_listener) - # Keep the scenario binary_sensors' state current: react to their - # condition entities (immediate) and refresh every minute (time/date - # scenarios and any dependency not captured by entity extraction). - scenario_watch: set[str] = ( - set().union(*self._scenario_cond_entities.values()) if self._scenario_cond_entities else set() - ) - if scenario_watch: - self.context.hass_api.subscribe_state(sorted(scenario_watch), self.async_refresh_scenario_states) - self.context.hass_api.subscribe_interval(60, self.async_refresh_scenario_states) + # Keep the scenario binary_sensors' state current: react to their condition entities + # (immediate, and only for the scenarios that depend on the entity that changed), plus + # a periodic sweep for conditions no entity change announces - time windows, sun, and + # templates whose dependencies could not be extracted. + # Evaluating conditions costs whatever the conditions cost, so the whole mechanism is + # switchable: `scenario_state: {enabled: false}` subscribes to nothing and starts no + # timer, and `refresh_interval: 0` keeps the reactive path without the sweep. + if self.scenario_state_enabled: + scenario_watch: set[str] = set(self._scenario_by_entity) + if scenario_watch: + self.context.hass_api.subscribe_state(sorted(scenario_watch), self.async_refresh_scenario_states) + if self.scenario_state_interval: + self.context.hass_api.subscribe_interval(self.scenario_state_interval, self.async_refresh_scenario_states) housekeeping_schedule = self.housekeeping.get(CONF_HOUSEKEEPING_TIME) if housekeeping_schedule: @@ -663,6 +675,29 @@ def _collect_scenario_condition_entities(self) -> dict[str, set[str]]: mapping[name] = ents return mapping + @property + def scenario_state_enabled(self) -> bool: + return bool(self.scenario_state_config.get(CONF_ENABLED, True)) + + @property + def scenario_state_interval(self) -> int: + return int(self.scenario_state_config.get(CONF_REFRESH_INTERVAL, SCENARIO_STATE_REFRESH_DEFAULT)) + + def _index_scenarios_by_entity(self) -> dict[str, set[str]]: + """Reverse of _collect_scenario_condition_entities: entity -> scenarios depending on it. + + Used to re-evaluate only the scenarios a state change can actually affect, instead of + the whole registry on every event. + """ + index: dict[str, set[str]] = {} + for name, entities in self._scenario_cond_entities.items(): + scenario = self.context.scenario_registry.scenarios.get(name) + if scenario is not None and not scenario.expose_state: + continue + for entity_id in entities: + index.setdefault(entity_id, set()).add(name) + return index + def _scenario_state(self, scenario: Scenario, cvars: ConditionVariables | None = None) -> str: """State to expose for a scenario binary_sensor. @@ -671,6 +706,8 @@ def _scenario_state(self, scenario: Scenario, cvars: ConditionVariables | None = - otherwise ON/OFF from a neutral evaluation (current occupancy, medium priority), the same basis as enquire_active_scenarios(). """ + if not scenario.expose_state: + return STATE_UNKNOWN if not scenario.conditions_config: return STATE_UNKNOWN if not getattr(self, "_scenario_cond_entities", {}).get(scenario.name): @@ -681,7 +718,7 @@ def _scenario_state(self, scenario: Scenario, cvars: ConditionVariables | None = return STATE_ON if scenario.evaluate(cvars) else STATE_OFF @callback - def async_refresh_scenario_states(self, *_args: Any) -> None: + def async_refresh_scenario_states(self, *args: Any) -> None: """Re-evaluate and re-publish the state of every scenario binary_sensor. Triggered by the 1-minute timer (time/date scenarios and any dependency @@ -689,9 +726,22 @@ def async_refresh_scenario_states(self, *_args: Any) -> None: condition entities (immediate reactivity). Pure in-memory evaluation over cached states; no I/O. """ + if not self.scenario_state_enabled: + return + names: set[str] | None = None + if args: + event = args[0] + entity_id = getattr(event, "data", {}).get("entity_id") if hasattr(event, "data") else None + if entity_id is not None: + names = self._scenario_by_entity.get(entity_id, set()) + if not names: + return + occupiers = self.context.people_registry.determine_occupancy() cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None) for name, scenario in self.context.scenario_registry.scenarios.items(): + if names is not None and name not in names: + continue self.context.hass_api.set_state( f"binary_sensor.{DOMAIN}_scenario_{name}", self._scenario_state(scenario, cvars), diff --git a/custom_components/supernotify/scenario.py b/custom_components/supernotify/scenario.py index 75f516326..498a46eca 100644 --- a/custom_components/supernotify/scenario.py +++ b/custom_components/supernotify/scenario.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Any from homeassistant.const import CONF_ENABLED + +from .const import CONF_EXPOSE_STATE from homeassistant.helpers import issue_registry as ir from .const import ATTR_MEDIA @@ -63,6 +65,7 @@ def __init__( self.hass_api: HomeAssistantAPI = hass_api self.delivery_registry = delivery_registry self.enabled: bool = scenario_definition.get(CONF_ENABLED, True) + self.expose_state: bool = scenario_definition.get(CONF_EXPOSE_STATE, True) self.name: str = name self.alias: str | None = scenario_definition.get(CONF_ALIAS) self.conditions: ConditionsFunc | None = None diff --git a/custom_components/supernotify/schema.py b/custom_components/supernotify/schema.py index 0d6da3dae..7c7ef497e 100644 --- a/custom_components/supernotify/schema.py +++ b/custom_components/supernotify/schema.py @@ -121,7 +121,10 @@ CONF_PTZ_PRESET_DEFAULT, CONF_RECIPIENTS, CONF_RECIPIENTS_DISCOVERY, + CONF_REFRESH_INTERVAL, + CONF_SCENARIO_STATE, CONF_SCENARIOS, + SCENARIO_STATE_REFRESH_DEFAULT, CONF_SELECTION, CONF_SELECTION_RANK, CONF_SIZE, @@ -159,6 +162,7 @@ TARGET_USE_ON_NO_ACTION_TARGETS, TARGET_USE_ON_NO_DELIVERY_TARGETS, TRANSPORT_VALUES, + CONF_EXPOSE_STATE, ) @@ -279,6 +283,16 @@ def validate_scenario_names(scenarios: dict) -> dict: vol.Optional(CONF_NAME): cv.string, }) +SCENARIO_STATE_SCHEMA = vol.Schema({ + # Evaluating scenario conditions to publish binary_sensor state costs whatever the + # conditions cost, which for template-heavy scenarios is not free on small hardware. + vol.Optional(CONF_ENABLED, default=True): cv.boolean, + # Sweep every scenario this often, for conditions no entity change can announce (time + # windows, sun, templates whose dependencies could not be extracted). 0 disables the + # sweep and leaves state entirely event driven. + vol.Optional(CONF_REFRESH_INTERVAL, default=SCENARIO_STATE_REFRESH_DEFAULT): cv.positive_int, +}) + SNOOZE_SCHEMA = vol.Schema({vol.Optional(CONF_SNOOZE_TIME, default=60 * 60): cv.positive_int}) DELIVERY_CONFIG_SCHEMA = vol.Schema({ # shared by Transport Defaults and Delivery definitions @@ -407,6 +421,7 @@ def _migrate_condition(config: dict) -> dict: vol.Schema({ vol.Optional(CONF_ALIAS): cv.string, vol.Optional(CONF_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_EXPOSE_STATE, default=True): cv.boolean, vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_MEDIA): MEDIA_SCHEMA, vol.Optional(CONF_ACTION_GROUP_NAMES, default=[]): vol.All(cv.ensure_list, [cv.string]), @@ -505,6 +520,7 @@ def _mobile_action_uri(value: str) -> str: vol.Optional(CONF_DUPE_CHECK, default=dict): NOTIFICATION_DUPE_SCHEMA, vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean, vol.Optional(CONF_RECIPIENTS_DISCOVERY, default=True): cv.boolean, + vol.Optional(CONF_SCENARIO_STATE, default=dict): SCENARIO_STATE_SCHEMA, }, extra=vol.ALLOW_EXTRA, ) diff --git a/docs/usage/scenarios.md b/docs/usage/scenarios.md index 208a6bfe2..80738c5b3 100644 --- a/docs/usage/scenarios.md +++ b/docs/usage/scenarios.md @@ -213,3 +213,38 @@ scenarios: data: priority: critical ``` + +## Scenario state + +Each scenario is exposed as `binary_sensor.supernotify_scenario_`, reporting whether its +conditions currently hold: `on`, `off`, or `unknown` for a scenario that has nothing to evaluate +between notifications — one with no conditions, or whose conditions depend only on the priority of +the notification being sent. + +The state is kept current in two ways. A change to an entity referenced by a scenario's conditions +re-evaluates the scenarios that depend on that entity, immediately. A periodic sweep then covers +what no entity change can announce: time windows, sun position, and templates whose dependencies +could not be determined statically. + +Evaluating conditions costs whatever those conditions cost, which for template-heavy scenarios on +small hardware is worth controlling: + +```yaml +scenario_state: + enabled: true # false subscribes to nothing and starts no timer + refresh_interval: 60 # seconds; 0 keeps the reactive path and drops the sweep +``` + +An individual scenario can be kept out of it, which is useful for one expensive template among +otherwise cheap scenarios: + +```yaml +scenarios: + everything_open: + expose_state: false + condition: + - condition: template + value_template: "{{ states.binary_sensor | selectattr('state','eq','on') | list | count > 3 }}" +``` + +Such a scenario still works normally for notifications; it simply stays `unknown` as an entity. diff --git a/tests/components/supernotify/test_scenario_state.py b/tests/components/supernotify/test_scenario_state.py index e332e9061..e55cbd00f 100644 --- a/tests/components/supernotify/test_scenario_state.py +++ b/tests/components/supernotify/test_scenario_state.py @@ -74,3 +74,96 @@ def test_stateful_uses_neutral_condition_variables() -> None: # occupancy was queried to build the neutral evaluation context me.context.people_registry.determine_occupancy.assert_called_once() scenario.evaluate.assert_called_once() + + +def _action_with_scenarios(names_to_entities: dict[str, set[str]], expose: dict[str, bool] | None = None) -> MagicMock: + """A mock SupernotifyAction wired with a scenario registry and the entity index.""" + expose = expose or {} + me = MagicMock() + me._scenario_cond_entities = dict(names_to_entities) + me.scenario_state_config = {} + scenarios = {} + for name in names_to_entities: + scenario = MagicMock() + scenario.name = name + scenario.conditions_config = [{"condition": "state"}] + scenario.evaluate.return_value = True + scenario.expose_state = expose.get(name, True) + scenario.attributes.return_value = {} + scenarios[name] = scenario + me.context.scenario_registry.scenarios = scenarios + me.context.people_registry.determine_occupancy.return_value = {} + me._scenario_by_entity = SupernotifyAction._index_scenarios_by_entity(me) + return me + + +def test_entity_index_maps_entities_to_dependent_scenarios() -> None: + me = _action_with_scenarios({ + "dnd": {"input_boolean.dnd"}, + "night": {"input_boolean.dnd", "sun.sun"}, + "away": {"person.lorenzo"}, + }) + assert me._scenario_by_entity["input_boolean.dnd"] == {"dnd", "night"} + assert me._scenario_by_entity["sun.sun"] == {"night"} + assert me._scenario_by_entity["person.lorenzo"] == {"away"} + + +def test_entity_index_skips_scenarios_opted_out() -> None: + """A scenario with expose_state: false is never woken by an entity change.""" + me = _action_with_scenarios({"dnd": {"input_boolean.dnd"}, "heavy": {"input_boolean.dnd"}}, expose={"heavy": False}) + assert me._scenario_by_entity["input_boolean.dnd"] == {"dnd"} + + +def _refreshed(me: MagicMock) -> set[str]: + return {call.args[0].rsplit("_", 1)[-1] for call in me.context.hass_api.set_state.call_args_list} + + +def test_state_change_refreshes_only_dependent_scenarios() -> None: + """The point of the entity index: one sensor changing must not re-evaluate every scenario, + which is what makes the cost proportional to the change rather than to the config size.""" + me = _action_with_scenarios({ + "dnd": {"input_boolean.dnd"}, + "night": {"input_boolean.dnd", "sun.sun"}, + "away": {"person.lorenzo"}, + }) + me.scenario_state_enabled = True + event = MagicMock() + event.data = {"entity_id": "person.lorenzo"} + SupernotifyAction.async_refresh_scenario_states(me, event) + assert _refreshed(me) == {"away"} + + +def test_state_change_for_unrelated_entity_does_nothing() -> None: + me = _action_with_scenarios({"dnd": {"input_boolean.dnd"}}) + me.scenario_state_enabled = True + event = MagicMock() + event.data = {"entity_id": "light.kitchen"} + SupernotifyAction.async_refresh_scenario_states(me, event) + me.context.hass_api.set_state.assert_not_called() + + +def test_periodic_sweep_refreshes_everything() -> None: + """The timer has no entity, so it evaluates the whole registry - the safety net for time + windows and templates whose dependencies could not be extracted.""" + me = _action_with_scenarios({"dnd": {"input_boolean.dnd"}, "away": {"person.lorenzo"}}) + me.scenario_state_enabled = True + SupernotifyAction.async_refresh_scenario_states(me) + assert _refreshed(me) == {"dnd", "away"} + + +def test_refresh_is_a_no_op_when_disabled() -> None: + me = _action_with_scenarios({"dnd": {"input_boolean.dnd"}}) + me.scenario_state_enabled = False + SupernotifyAction.async_refresh_scenario_states(me) + me.context.hass_api.set_state.assert_not_called() + + +def test_scenario_opted_out_of_state_stays_unknown() -> None: + me = MagicMock() + me._scenario_cond_entities = {"s": {"input_boolean.dnd"}} + scenario = MagicMock() + scenario.name = "s" + scenario.conditions_config = [{"condition": "state"}] + scenario.evaluate.return_value = True + scenario.expose_state = False + assert SupernotifyAction._scenario_state(me, scenario) == STATE_UNKNOWN From 4a9b6ac1b7d66e4ca8867bdcd85d3ca214e3e536 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:45:53 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- custom_components/supernotify/notify.py | 8 ++++---- custom_components/supernotify/scenario.py | 4 +--- custom_components/supernotify/schema.py | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/custom_components/supernotify/notify.py b/custom_components/supernotify/notify.py index 33c6343d4..919c02dd8 100644 --- a/custom_components/supernotify/notify.py +++ b/custom_components/supernotify/notify.py @@ -14,13 +14,13 @@ ) from homeassistant.components.notify.legacy import BaseNotificationService from homeassistant.const import ( + CONF_ENABLED, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON, STATE_UNKNOWN, EntityCategory, Platform, - CONF_ENABLED, ) from homeassistant.core import ( Event, @@ -39,9 +39,6 @@ from .archive import ARCHIVE_PURGE_MIN_INTERVAL, NotificationArchive from .common import DupeChecker, sanitize from .const import ( - CONF_REFRESH_INTERVAL, - CONF_SCENARIO_STATE, - SCENARIO_STATE_REFRESH_DEFAULT, ATTR_ACTION, ATTR_DATA, CONF_ACTION_GROUPS, @@ -59,11 +56,14 @@ CONF_MOBILE_DISCOVERY, CONF_RECIPIENTS, CONF_RECIPIENTS_DISCOVERY, + CONF_REFRESH_INTERVAL, + CONF_SCENARIO_STATE, CONF_SCENARIOS, CONF_SNOOZE, CONF_TEMPLATE_PATH, CONF_TRANSPORTS, PRIORITY_MEDIUM, + SCENARIO_STATE_REFRESH_DEFAULT, ) from .context import Context from .delivery import DeliveryRegistry diff --git a/custom_components/supernotify/scenario.py b/custom_components/supernotify/scenario.py index 498a46eca..cb97df18b 100644 --- a/custom_components/supernotify/scenario.py +++ b/custom_components/supernotify/scenario.py @@ -5,11 +5,9 @@ from typing import TYPE_CHECKING, Any from homeassistant.const import CONF_ENABLED - -from .const import CONF_EXPOSE_STATE from homeassistant.helpers import issue_registry as ir -from .const import ATTR_MEDIA +from .const import ATTR_MEDIA, CONF_EXPOSE_STATE from .model import DeliveryCustomization if TYPE_CHECKING: diff --git a/custom_components/supernotify/schema.py b/custom_components/supernotify/schema.py index 7c7ef497e..7051de775 100644 --- a/custom_components/supernotify/schema.py +++ b/custom_components/supernotify/schema.py @@ -97,6 +97,7 @@ CONF_DUPE_POLICY, CONF_DURATION, CONF_ENCRYPTION, + CONF_EXPOSE_STATE, CONF_HOUSEKEEPING, CONF_HOUSEKEEPING_TIME, CONF_LINKS, @@ -124,7 +125,6 @@ CONF_REFRESH_INTERVAL, CONF_SCENARIO_STATE, CONF_SCENARIOS, - SCENARIO_STATE_REFRESH_DEFAULT, CONF_SELECTION, CONF_SELECTION_RANK, CONF_SIZE, @@ -152,6 +152,7 @@ PTZ_METHOD_VALUES, RESERVED_DATA_KEYS, RESERVED_SCENARIO_NAMES, + SCENARIO_STATE_REFRESH_DEFAULT, SELECTION_VALUES, TARGET_REQUIRE_ALWAYS, TARGET_REQUIRE_NEVER, @@ -162,7 +163,6 @@ TARGET_USE_ON_NO_ACTION_TARGETS, TARGET_USE_ON_NO_DELIVERY_TARGETS, TRANSPORT_VALUES, - CONF_EXPOSE_STATE, )