Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions custom_components/supernotify/hass_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions custom_components/supernotify/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions custom_components/supernotify/snoozer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/usage/snoozing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 43 additions & 1 deletion tests/components/supernotify/test_snooze_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading