Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<name>` 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: <seconds>}` 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')
Expand Down
1 change: 1 addition & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 4 additions & 0 deletions custom_components/supernotify/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
6 changes: 5 additions & 1 deletion custom_components/supernotify/hass_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand Down
118 changes: 117 additions & 1 deletion custom_components/supernotify/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from homeassistant.components.notify.legacy import BaseNotificationService
from homeassistant.const import (
CONF_ENABLED,
EVENT_HOMEASSISTANT_STOP,
STATE_OFF,
STATE_ON,
Expand All @@ -31,6 +32,7 @@
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import condition
from homeassistant.helpers.json import ExtendedJSONEncoder

from . import DOMAIN
Expand All @@ -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
Expand Down Expand Up @@ -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),
)


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion custom_components/supernotify/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions custom_components/supernotify/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
CONF_DUPE_POLICY,
CONF_DURATION,
CONF_ENCRYPTION,
CONF_EXPOSE_STATE,
CONF_HOUSEKEEPING,
CONF_HOUSEKEEPING_TIME,
CONF_LINKS,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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,
)
Expand Down
35 changes: 35 additions & 0 deletions docs/usage/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,38 @@ scenarios:
data:
priority: critical
```

## Scenario state

Each scenario is exposed as `binary_sensor.supernotify_scenario_<name>`, 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.
4 changes: 4 additions & 0 deletions tests/components/supernotify/hass_setup_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import json
import logging
import uuid
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading