diff --git a/CHANGELOG.md b/CHANGELOG.md index 822a8bd71..6448bc6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ Gratitude to [@lollox80](https://github.com/lollox80) for contributing 4 new tra ## 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`. 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 - 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/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/hass_api.py b/custom_components/supernotify/hass_api.py index 72a148dec..877c6cf0b 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: @@ -29,6 +29,7 @@ import socket import threading from contextlib import contextmanager +from datetime import timedelta from typing import TYPE_CHECKING, cast import homeassistant.components.trace @@ -168,6 +169,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_avail("loop_thread_id") and self._hass.loop_thread_id == threading.get_ident() diff --git a/custom_components/supernotify/notify.py b/custom_components/supernotify/notify.py index 8551b6ceb..919c02dd8 100644 --- a/custom_components/supernotify/notify.py +++ b/custom_components/supernotify/notify.py @@ -14,6 +14,7 @@ ) from homeassistant.components.notify.legacy import BaseNotificationService from homeassistant.const import ( + CONF_ENABLED, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON, @@ -31,6 +32,7 @@ callback, ) from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import condition from homeassistant.helpers.json import ExtendedJSONEncoder from . import DOMAIN @@ -54,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 @@ -153,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), ) @@ -439,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) @@ -482,9 +490,24 @@ 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._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 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: @@ -632,6 +655,99 @@ 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: + _LOGGER.debug("SUPERNOTIFY could not extract entities for scenario %s", name) + 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. + + - 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.expose_state: + return STATE_UNKNOWN + 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. + """ + 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), + sanitize(scenario.attributes(include_condition=False)), + ) + def expose_entity( self, entity_name: str, @@ -678,7 +794,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/custom_components/supernotify/scenario.py b/custom_components/supernotify/scenario.py index 75f516326..cb97df18b 100644 --- a/custom_components/supernotify/scenario.py +++ b/custom_components/supernotify/scenario.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_ENABLED 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: @@ -63,6 +63,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..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, @@ -121,6 +122,8 @@ CONF_PTZ_PRESET_DEFAULT, CONF_RECIPIENTS, CONF_RECIPIENTS_DISCOVERY, + CONF_REFRESH_INTERVAL, + CONF_SCENARIO_STATE, CONF_SCENARIOS, CONF_SELECTION, CONF_SELECTION_RANK, @@ -149,6 +152,7 @@ PTZ_METHOD_VALUES, RESERVED_DATA_KEYS, RESERVED_SCENARIO_NAMES, + SCENARIO_STATE_REFRESH_DEFAULT, SELECTION_VALUES, TARGET_REQUIRE_ALWAYS, TARGET_REQUIRE_NEVER, @@ -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/hass_setup_lib.py b/tests/components/supernotify/hass_setup_lib.py index 91f40c2bf..fc8b91a9e 100644 --- a/tests/components/supernotify/hass_setup_lib.py +++ b/tests/components/supernotify/hass_setup_lib.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json import logging import uuid @@ -140,6 +141,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..e55cbd00f --- /dev/null +++ b/tests/components/supernotify/test_scenario_state.py @@ -0,0 +1,169 @@ +"""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() + + +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