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
6 changes: 6 additions & 0 deletions custom_components/supernotify/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ async def initialize(self) -> None:
# Every entity - switches, binary_sensors and counters - is a real platform entity, added
# once this method returns (see __init__.py), and keeps its own state current
self.context.hass_api.subscribe_event("mobile_app_notification_action", self.on_mobile_action)
self.context.hass_api.subscribe_mobile_app_changes(self.async_mobile_apps_changed)

housekeeping_schedule = self.housekeeping.get(CONF_HOUSEKEEPING_TIME)
if housekeeping_schedule:
Expand Down Expand Up @@ -426,6 +427,11 @@ def on_mobile_action(self, event: Event) -> None:
self.context.snoozer.handle_command_event(event, self.context.people_registry.enabled_recipients())

@callback
async def async_mobile_apps_changed(self) -> None:
_LOGGER.info("SUPERNOTIFY Mobile app devices changed, rediscovering")
self.context.people_registry.refresh_mobile_devices()
self.context.snoozer.purge_snoozes()

async def async_nightly_tasks(self, now: dt.datetime) -> None:
_LOGGER.info("SUPERNOTIFY Housekeeping starting as scheduled at %s", now)
await self.context.archive.cleanup()
Expand Down
57 changes: 53 additions & 4 deletions custom_components/supernotify/hass_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CONF_DEVICE_ID,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change, async_track_time_interval
from homeassistant.helpers.storage import Store
Expand All @@ -26,7 +27,7 @@

if TYPE_CHECKING:
import asyncio
from collections.abc import Callable, Iterable
from collections.abc import Awaitable, Callable, Iterable

import aiohttp
from anyio import Path
Expand All @@ -51,8 +52,9 @@
from homeassistant.components.trace.const import DATA_TRACE
from homeassistant.components.trace.models import ActionTrace
from homeassistant.components.trace.util import async_store_trace
from homeassistant.const import ATTR_DOMAIN, ATTR_SERVICE, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED
from homeassistant.core import Context as HomeAssistantContext
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.core import Event, HomeAssistant, SupportsResponse, callback
from homeassistant.exceptions import ConditionError, ConditionErrorContainer, HomeAssistantError, IntegrationError
from homeassistant.helpers import condition as condition_helper
from homeassistant.helpers import device_registry as dr
Expand Down Expand Up @@ -82,6 +84,8 @@
ATTR_MANUFACTURER = "manufacturer"
ATTR_MODEL = "model"

MOBILE_APP_DOMAIN = "mobile_app"

_LOGGER = logging.getLogger(__name__)


Expand Down Expand Up @@ -212,9 +216,41 @@ def disconnect(self) -> None:
_LOGGER.error("SUPERNOTIFY Failed to unsubscribe: %s", e)
_LOGGER.debug("SUPERNOTIFY Disconnection complete")

def subscribe_event(self, event: EventType | str, callback: Callable) -> None:
def subscribe_event(self, event: EventType[Any] | str, callback: Callable) -> None:
self.unsubscribes.append(self._hass.bus.async_listen(event, callback))

def subscribe_mobile_app_changes(self, on_change: Callable[[], Awaitable[None]], cooldown: float = 10) -> None:
"""Call `on_change` once things settle after a mobile_app device, or its notify action, comes or goes

Re-pairing or renaming a phone changes its notify action, and HA registers that action a
little after the device itself, so both kinds of change are debounced into one call
"""
debouncer: Debouncer = Debouncer(self._hass, _LOGGER, cooldown=cooldown, immediate=False, function=on_change)

@callback
def on_device(event: Event) -> None:
device_id: str | None = event.data.get("device_id")
if event.data.get("action") == "remove":
relevant: bool = device_id in self._mobile_apps_by_device_id
else:
dev_reg: DeviceRegistry | None = self._device_registry()
device = dev_reg.async_get(device_id) if dev_reg and device_id else None
relevant = device is not None and any(i and i[0] == MOBILE_APP_DOMAIN for i in device.identifiers)
if relevant:
debouncer.async_schedule_call()

@callback
def on_service(event: Event) -> None:
if event.data.get(ATTR_DOMAIN) == "notify" and str(event.data.get(ATTR_SERVICE, "")).startswith(
f"{MOBILE_APP_DOMAIN}_"
):
debouncer.async_schedule_call()

self.subscribe_event(dr.EVENT_DEVICE_REGISTRY_UPDATED, on_device)
self.subscribe_event(EVENT_SERVICE_REGISTERED, on_service)
self.subscribe_event(EVENT_SERVICE_REMOVED, on_service)
self.unsubscribes.append(debouncer.async_cancel)

def subscribe_state(self, entity_ids: str | Iterable[str], callback: Callable) -> None:
self.unsubscribes.append(async_track_state_change_event(self._hass, entity_ids, callback))

Expand All @@ -236,6 +272,11 @@ def is_state(self, entity_id: str, state: str) -> bool:
def has_service(self, domain: str, service: str) -> bool:
return self._hass.services.has_service(domain, service)

def mobile_app_action_exists(self, mobile_app_id: str) -> bool:
"""Whether a mobile app can be notified, by its notify action or its notify entity"""
action: str = mobile_app_id.removeprefix("notify.")
return self.has_service("notify", action) or self.get_state(f"notify.{action}") is not None

def entity_ids_for_domain(self, domain: str) -> list[str]:
return self._hass.states.async_entity_ids(domain)

Expand Down Expand Up @@ -690,6 +731,9 @@ def raise_issue(
is_fixable=is_fixable,
)

def delete_issue(self, issue_id: str) -> None:
ir.async_delete_issue(self._hass, DOMAIN, issue_id)

def mobile_app_by_tracker(self, device_tracker: str) -> TrackedDeviceDetails | None:
return self._mobile_apps_by_tracker.get(device_tracker)

Expand All @@ -710,9 +754,14 @@ def build_mobile_app_cache(self) -> None:
_LOGGER.warning("SUPERNOTIFY Unable to discover devices for - no entity registry found")
return

# rebuilt from scratch, so a device that has gone, or been re-paired under a new name, goes too
self._mobile_apps_by_tracker.clear()
self._mobile_apps_by_app_id.clear()
self._mobile_apps_by_device_id.clear()
self._mobile_apps_by_user_id.clear()
found: int = 0
complete: int = 0
for mobile_app_info in self.discover_devices("mobile_app"):
for mobile_app_info in self.discover_devices(MOBILE_APP_DOMAIN):
try:
mobile_app_id: str = f"mobile_app_{slugify(mobile_app_info.device_name)}"
device_tracker: str | None = None
Expand Down
85 changes: 57 additions & 28 deletions custom_components/supernotify/people.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ def __init__(self, config: dict[str, Any] | None, default_mobile_discovery: bool
c[CONF_MOBILE_APP_ID]: c for c in config.get(CONF_MOBILE_DEVICES, [])
}
self.disabled_mobile_app_ids: list[str] = [k for k, v in self.mobile_devices.items() if not v.get(CONF_ENABLED, True)]
# as configured, to start again from each time the discovered devices are refreshed
self._configured_mobile_devices: dict[str, dict[str, str | list[str] | None]] = {
k: dict(v) for k, v in self.mobile_devices.items()
}
self._mobile_app_issues: set[str] = set()
_LOGGER.debug("SUPERNOTIFY Recipient config %s -> %s", config, self.as_dict(redact=True))

def initialize(self, people_registry: PeopleRegistry) -> None:
Expand All @@ -218,6 +223,30 @@ def initialize(self, people_registry: PeopleRegistry) -> None:
self._target.extend(ATTR_EMAIL, self.email)
if self.phone_number:
self._target.extend(ATTR_PHONE, self.phone_number)
self.resolve_mobile_devices(people_registry)
if not self.user_id or not self.alias:
attrs: dict[str, Any] | None = people_registry.person_attributes(self.entity_id)
if attrs:
if attrs.get(ATTR_USER_ID) and isinstance(attrs.get(ATTR_USER_ID), str):
self.user_id = attrs.get(ATTR_USER_ID)
if attrs.get(ATTR_ALIAS) and isinstance(attrs.get(ATTR_ALIAS), str):
self.alias = attrs.get(ATTR_ALIAS)
if not self.alias and attrs.get(ATTR_FRIENDLY_NAME) and isinstance(attrs.get(ATTR_FRIENDLY_NAME), str):
self.alias = attrs.get(ATTR_FRIENDLY_NAME)
_LOGGER.debug("SUPERNOTIFY Person attrs found for %s: %s,%s", self.entity_id, self.alias, self.user_id)
else:
_LOGGER.debug("SUPERNOTIFY No person attrs found for %s", self.entity_id)
_LOGGER.debug("SUPERNOTIFY Recipient %s target: %s", self.entity_id, self._target.as_dict(redact=True))

def on_notification(self, context: Context | None = None) -> None:
# Record that a notification has occurred for this person
if self.notify_entity is not None:
self.notify_entity.record_notification(context)

def resolve_mobile_devices(self, people_registry: PeopleRegistry) -> None:
"""The configured mobile devices plus those discovered now - again whenever one is re-paired or renamed"""
previous: list[str] = list(self.mobile_devices)
self.mobile_devices = {k: dict(v) for k, v in self._configured_mobile_devices.items()}
if self.mobile_discovery:
# a known user_id (explicitly configured, or already backfilled below from a prior
# initialize()) resolves devices directly, without needing a Person entity at all
Expand Down Expand Up @@ -246,37 +275,30 @@ def initialize(self, people_registry: PeopleRegistry) -> None:
)
else:
_LOGGER.info("SUPERNOTIFY Unable to find mobile devices for %s", self.entity_id)
self._target.remove(ATTR_MOBILE_APP_ID, previous)
if self.mobile_devices:
self._target.extend(ATTR_MOBILE_APP_ID, list(self.enabled_mobile_devices.keys()))
for mobile_app_id in self.enabled_mobile_devices:
action: str = mobile_app_id.removeprefix("notify.")
hass_api = people_registry.hass_api
if not hass_api.has_service("notify", action) and hass_api.get_state(f"notify.{action}") is None:
self.check_mobile_actions(people_registry.hass_api)

def check_mobile_actions(self, hass_api: HomeAssistantAPI) -> None:
"""Raise a repair for an enabled mobile device with no notify action, and clear it once that's resolved"""
missing: set[str] = set()
for mobile_app_id in self.enabled_mobile_devices:
action: str = mobile_app_id.removeprefix("notify.")
if not hass_api.mobile_app_action_exists(action):
issue_id: str = f"recipient_{self.name}_mobile_app_{action}_not_found"
missing.add(issue_id)
if issue_id not in self._mobile_app_issues:
_LOGGER.warning("SUPERNOTIFY No notify action found for %s mobile device %s", self.entity_id, action)
hass_api.raise_issue(
f"recipient_{self.name}_mobile_app_{action}_not_found",
issue_key="recipient_mobile_app_not_found",
issue_map={"recipient": self.entity_id, "mobile_app": action},
learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/people/",
)
if not self.user_id or not self.alias:
attrs: dict[str, Any] | None = people_registry.person_attributes(self.entity_id)
if attrs:
if attrs.get(ATTR_USER_ID) and isinstance(attrs.get(ATTR_USER_ID), str):
self.user_id = attrs.get(ATTR_USER_ID)
if attrs.get(ATTR_ALIAS) and isinstance(attrs.get(ATTR_ALIAS), str):
self.alias = attrs.get(ATTR_ALIAS)
if not self.alias and attrs.get(ATTR_FRIENDLY_NAME) and isinstance(attrs.get(ATTR_FRIENDLY_NAME), str):
self.alias = attrs.get(ATTR_FRIENDLY_NAME)
_LOGGER.debug("SUPERNOTIFY Person attrs found for %s: %s,%s", self.entity_id, self.alias, self.user_id)
else:
_LOGGER.debug("SUPERNOTIFY No person attrs found for %s", self.entity_id)
_LOGGER.debug("SUPERNOTIFY Recipient %s target: %s", self.entity_id, self._target.as_dict(redact=True))

def on_notification(self, context: Context | None = None) -> None:
# Record that a notification has occurred for this person
if self.notify_entity is not None:
self.notify_entity.record_notification(context)
hass_api.raise_issue(
issue_id,
issue_key="recipient_mobile_app_not_found",
issue_map={"recipient": self.entity_id, "mobile_app": action},
learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/people/",
)
for issue_id in self._mobile_app_issues - missing:
hass_api.delete_issue(issue_id)
self._mobile_app_issues = missing

@property
def enabled_mobile_devices(self) -> dict[str, dict[str, str | list[str] | None]]:
Expand Down Expand Up @@ -407,6 +429,13 @@ async def initialize(self) -> None:

self.people[recipient.entity_id] = recipient

def refresh_mobile_devices(self) -> None:
"""Rediscover every recipient's mobile devices, after one was added, removed, re-paired or renamed"""
self.hass_api.build_mobile_app_cache()
for recipient in self.people.values():
recipient.resolve_mobile_devices(self)
self.async_refresh_entity(recipient.name)

def register_entity(self, name: str, entity: SupernotifyRecipientBinarySensor) -> None:
"""Called by SupernotifyRecipientBinarySensor.async_added_to_hass()."""
self._entities[name] = entity
Expand Down
11 changes: 10 additions & 1 deletion custom_components/supernotify/snoozer.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,12 +313,21 @@ def register_snooze(
)

def purge_snoozes(self) -> None:
to_del: list[str] = [k for k, v in self.snoozes.items() if not v.active()]
to_del: list[str] = [k for k, v in self.snoozes.items() if not v.active() or self._mobile_app_gone(v)]
for k in to_del:
del self.snoozes[k]
if to_del:
self._persist()

def _mobile_app_gone(self, snooze: Snooze) -> bool:
"""A snooze on a mobile device whose notify action no longer exists, e.g. re-paired under a new name"""
return (
snooze.target_type == QualifiedTargetType.MOBILE
and isinstance(snooze.target, str)
and self.hass_api is not None
and not self.hass_api.mobile_app_action_exists(snooze.target)
)

def clear(self) -> int:
cleared: int = len(self.snoozes)
self.snoozes.clear()
Expand Down
14 changes: 12 additions & 2 deletions custom_components/supernotify/transports/mobile_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,12 +437,22 @@ async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = Non
simple_target = (
mobile_target if not Target.is_notify_entity(mobile_target) else mobile_target.replace("notify.", "")
)
_LOGGER.warning("SUPERNOTIFY Failed to send to %s, snoozing for a day", simple_target)
# a device whose notify action has gone (removed, or re-paired under another name)
# won't come back by waiting, so it gets a repair rather than a snooze that hides it
action_gone: bool = not self.context.hass_api.mobile_app_action_exists(simple_target)
if action_gone:
_LOGGER.warning("SUPERNOTIFY No notify action for %s, raising a repair", simple_target)
else:
_LOGGER.warning("SUPERNOTIFY Failed to send to %s, snoozing for a day", simple_target)
if self.people_registry:
# tie the mobile device back to a recipient for the snoozing API
for recipient in self.people_registry.enabled_recipients():
for md in recipient.mobile_devices:
if md in (simple_target, mobile_target):
if md not in (simple_target, mobile_target):
continue
if action_gone:
recipient.check_mobile_actions(self.context.hass_api)
else:
self.context.snoozer.register_snooze(
CommandType.SNOOZE,
target_type=QualifiedTargetType.MOBILE,
Expand Down
12 changes: 12 additions & 0 deletions tests/components/supernotify/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,3 +706,15 @@ async def test_send_message_exception_handling(mock_hass: Mock) -> None:
with patch("custom_components.supernotify.engine.Notification", side_effect=RuntimeError("boom")):
await uut.async_send_message("test message")
assert uut.failures == 1


async def test_async_mobile_apps_changed_refreshes_recipients_and_purges_snoozes(mock_hass: Mock) -> None:
uut = SupernotifyEngine(mock_hass, recipients=RECIPIENTS)
await uut.initialize()
uut.context.people_registry.refresh_mobile_devices = Mock() # type: ignore[method-assign]
uut.context.snoozer.purge_snoozes = Mock() # type: ignore[method-assign]

await uut.async_mobile_apps_changed()

uut.context.people_registry.refresh_mobile_devices.assert_called_once()
uut.context.snoozer.purge_snoozes.assert_called_once()
Loading
Loading