diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ff67edd..722ef2013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ As previously indicated, support for py3.13 and Home Assistant 2026.2 and earlie It may continue to work on older installations, however the build checks to verify against 2026.2/py3.13 are no longer part of the pre-release or release process. ## ✨ Enhancements +### Snoozing +- A `TAG` snooze also matches the automation or script that sent the notification, by `entity_id`, name or alias, so any notification raised by an automation can be paused on its own (*pause garage closed for an hour*) without adding `entity_id` to the notify call. The sender is found from the call's context, and a script started by an automation counts both. ### Mobile - If a mobile app is removed, its action goes away - this used to lead to stack traces in logs and the entire notification mapped as an error even if other deliveries succeeded. diff --git a/custom_components/supernotify/hass_api.py b/custom_components/supernotify/hass_api.py index 87be32bad..79863e6ce 100644 --- a/custom_components/supernotify/hass_api.py +++ b/custom_components/supernotify/hass_api.py @@ -279,6 +279,17 @@ def entity_spoken_names(self, entity_id: str) -> set[str]: names.extend(a for a in reg_entry.aliases if isinstance(a, str)) return {spoken_name(n) for n in names} + def sender_entity_ids(self, ha_context: HomeAssistantContext | None) -> list[str]: + """The automations and scripts whose current run carries this context, i.e. what sent a notification. + + An automation or script writes its state with the context of the run it starts, and the actions + of that run carry the same context (a script started by an automation gets it as parent), so + the sender is the automation or script whose state has the context's id or its parent's""" + if ha_context is None: + return [] + ids: set[str] = {i for i in (ha_context.id, ha_context.parent_id) if i} + return [s.entity_id for s in self._hass.states.async_all(("automation", "script")) if s.context.id in ids] + def entity_ids_named(self, name: str) -> list[str]: """Entities that can be called this, see entity_spoken_names()""" wanted: str = spoken_name(name) diff --git a/custom_components/supernotify/notification.py b/custom_components/supernotify/notification.py index f8e685155..5dddf6c61 100644 --- a/custom_components/supernotify/notification.py +++ b/custom_components/supernotify/notification.py @@ -131,6 +131,7 @@ def __init__( self.message: str | None = message self.context: Context = context self.ha_context: HAContext | None = ha_context + self._senders: list[str] | None = None self.people_registry: PeopleRegistry = context.people_registry self.delivery_registry: DeliveryRegistry = context.delivery_registry action_data = action_data or {} @@ -724,16 +725,26 @@ async def _schedule_deliveries(self, deliveries: dict[str, DeliveryTargetOverrid _LOGGER.error("SUPERNOTIFY Unexpected error in parallel delivery: %s", result) def snooze_tags(self) -> set[str]: - """What a tag snooze can name this notification by - its scenarios, and what the entities it's - about are called, from `entity_id` in its data or its camera""" + """What a tag snooze can name this notification by - its scenarios, what the entities it's + about are called, from `entity_id` in its data or its camera, and the automation or script + that sent it""" tags: set[str] = {spoken_name(s) for s in self.enabled_scenarios} entity_ids: list[str] = [str(e) for e in ensure_list(self.extra_data.get(ATTR_ENTITY_ID))] if self.media.get(ATTR_MEDIA_CAMERA_ENTITY_ID): entity_ids.append(self.media[ATTR_MEDIA_CAMERA_ENTITY_ID]) + if self.context.snoozer.has_tag_snooze(): # the sender is looked up only when it could matter + entity_ids.extend(self.senders()) for entity_id in entity_ids: tags.update(self.context.hass_api.entity_spoken_names(entity_id)) return tags + def senders(self) -> list[str]: + """The automations or scripts that sent this notification, found once from its context - a later + run of the same automation would change the context its state carries""" + if self._senders is None: + self._senders = self.context.hass_api.sender_entity_ids(self.ha_context) + return self._senders + def delivery_skip_reason(self, delivery: Delivery) -> SuppressionReason | None: """Why a selected delivery won't be attempted at all, checked before any targets are worked out""" if not delivery.transport.enabled: diff --git a/custom_components/supernotify/snoozer.py b/custom_components/supernotify/snoozer.py index 59ea50506..f2cf8f3c6 100644 --- a/custom_components/supernotify/snoozer.py +++ b/custom_components/supernotify/snoozer.py @@ -360,6 +360,9 @@ def current_snoozes(self, priority: str, delivery: Delivery) -> list[Snooze]: return inscope_snoozes + def has_tag_snooze(self) -> bool: + return any(s.active() and s.target_type == QualifiedTargetType.TAG for s in self.snoozes.values()) + def is_global_snooze(self, priority: str = PRIORITY_MEDIUM) -> bool: for snooze in self.snoozes.values(): if snooze.active() and snooze.recipient_type == RecipientType.EVERYONE: diff --git a/docs/usage/snoozing.md b/docs/usage/snoozing.md index d40f16e97..329994738 100644 --- a/docs/usage/snoozing.md +++ b/docs/usage/snoozing.md @@ -42,6 +42,9 @@ the tag is: - the name of one of the notification's scenarios, whether applied by the automation or selected by its conditions - the `entity_id`, object_id, friendly name or an alias of the entity in the notification's `entity_id` data, as the Frigate blueprint sends, or of its `media` camera - the object_id then the domain, so *driveway camera* matches `camera.driveway` +- the `entity_id`, object_id, friendly name or an alias of the automation or script that sent it, so *garage closed* covers + every notification from `automation.garage_closed` - nothing needs adding to the notify call. The sender is the automation or + script whose current run carries the notification's context (a script started by an automation counts both) Case, underscores and extra spaces are ignored, so *unknown vehicle* matches the `unknown_vehicle` scenario. The tag is matched as each notification is sent, so if a scenario and a camera are both called *driveway*, both diff --git a/tests/components/supernotify/test_snooze_enforcement.py b/tests/components/supernotify/test_snooze_enforcement.py index a2c1f015e..c18b36c15 100644 --- a/tests/components/supernotify/test_snooze_enforcement.py +++ b/tests/components/supernotify/test_snooze_enforcement.py @@ -3,7 +3,7 @@ from datetime import timedelta from typing import TYPE_CHECKING -from homeassistant.core import Event +from homeassistant.core import Context, Event from custom_components.supernotify.const import ATTR_ACTION, CONF_MOBILE_APP_ID, CONF_MOBILE_DEVICES, CONF_PERSON from custom_components.supernotify.model import CommandType, GlobalTargetType, QualifiedTargetType, RecipientType @@ -97,6 +97,48 @@ async def test_tag_snooze_matches_friendly_name_and_scenario(hass: HomeAssistant assert _delivered(await _send(ctx, "Something else")) +async def test_tag_snooze_matches_the_sending_automation(hass: HomeAssistant) -> None: + """The automation whose run carries the notification's context is a tag, by entity_id or name""" + run = Context() + other = Context() + hass.states.async_set("automation.thermostat_offline", "on", {"friendly_name": "Thermostat Offline"}, context=run) + hass.states.async_set("automation.garage_closed", "on", {"friendly_name": "Garage Closed"}, context=other) + ctx = TestingContext(homeassistant=hass, yaml=YAML, services={"notify": ["smtp"]}) + await ctx.test_initialize() + ctx.snoozer.register_snooze( + CommandType.SNOOZE, QualifiedTargetType.TAG, "automation.thermostat_offline", RecipientType.EVERYONE, None, None + ) + + offline = Notification(ctx, message="Bathroom thermostat offline", ha_context=run) + await offline.initialize() + await offline.deliver() + assert not _delivered(offline) + assert offline.senders() == ["automation.thermostat_offline"] + + garage = Notification(ctx, message="Garage closed", ha_context=other) + await garage.initialize() + await garage.deliver() + assert _delivered(garage) + assert _delivered(await _send(ctx, "Sent by hand, no context")) + + +async def test_tag_snooze_matches_a_script_started_by_an_automation(hass: HomeAssistant) -> None: + """A script's run has the automation's context as parent: both are senders, by name too""" + automation_run = Context() + script_run = Context(parent_id=automation_run.id) + hass.states.async_set("automation.hourly", "on", {"friendly_name": "Hourly"}, context=automation_run) + hass.states.async_set("script.chime", "on", {"friendly_name": "Hour Chime"}, context=script_run) + ctx = TestingContext(homeassistant=hass, yaml=YAML, services={"notify": ["smtp"]}) + await ctx.test_initialize() + ctx.snoozer.register_snooze(CommandType.SNOOZE, QualifiedTargetType.TAG, "hour chime", RecipientType.EVERYONE, None, None) + + uut = Notification(ctx, message="Ten o'clock", ha_context=script_run) + await uut.initialize() + await uut.deliver() + assert not _delivered(uut) + assert sorted(uut.senders()) == ["automation.hourly", "script.chime"] + + async def test_user_tag_snooze_only_silences_that_user(hass: HomeAssistant) -> None: ctx = TestingContext( homeassistant=hass,