Coverage for custom_components/supernotify/transports/mobile_push.py: 93%
107 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-21 23:31 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-21 23:31 +0000
1import logging
2from datetime import timedelta
3from typing import Any
5import httpx
6from bs4 import BeautifulSoup
7from homeassistant.components.notify.const import ATTR_DATA
9import custom_components.supernotify
10from custom_components.supernotify import (
11 ATTR_ACTION_CATEGORY,
12 ATTR_ACTION_URL,
13 ATTR_ACTION_URL_TITLE,
14 ATTR_MEDIA_CAMERA_ENTITY_ID,
15 ATTR_MEDIA_CLIP_URL,
16 ATTR_MEDIA_SNAPSHOT_URL,
17 ATTR_MOBILE_APP_ID,
18 CONF_MOBILE_APP_ID,
19 CONF_MOBILE_DEVICES,
20 CONF_PERSON,
21 OPTION_MESSAGE_USAGE,
22 OPTION_SIMPLIFY_TEXT,
23 OPTION_STRIP_URLS,
24 OPTION_TARGET_CATEGORIES,
25 TRANSPORT_MOBILE_PUSH,
26)
27from custom_components.supernotify.envelope import Envelope
28from custom_components.supernotify.model import (
29 CommandType,
30 MessageOnlyPolicy,
31 QualifiedTargetType,
32 RecipientType,
33 TargetRequired,
34 TransportConfig,
35)
36from custom_components.supernotify.transport import (
37 Transport,
38)
40_LOGGER = logging.getLogger(__name__)
43class MobilePushTransport(Transport):
44 name = TRANSPORT_MOBILE_PUSH
46 def __init__(self, *args: Any, **kwargs: Any) -> None:
47 super().__init__(*args, **kwargs)
48 self.action_titles: dict[str, str] = {}
50 @property
51 def default_config(self) -> TransportConfig:
52 config = TransportConfig()
53 config.delivery_defaults.target_required = TargetRequired.ALWAYS
54 config.delivery_defaults.options = {
55 OPTION_SIMPLIFY_TEXT: False,
56 OPTION_STRIP_URLS: False,
57 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD,
58 OPTION_TARGET_CATEGORIES: [ATTR_MOBILE_APP_ID],
59 }
60 return config
62 def validate_action(self, action: str | None) -> bool:
63 return action is None
65 async def action_title(self, url: str) -> str | None:
66 if url in self.action_titles:
67 return self.action_titles[url]
68 try:
69 async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=5.0)) as client:
70 resp: httpx.Response = await client.get(url, follow_redirects=True, timeout=5)
71 html = BeautifulSoup(resp.text, features="html.parser")
72 if html.title and html.title.string:
73 self.action_titles[url] = html.title.string
74 return html.title.string
75 except Exception as e:
76 _LOGGER.debug("SUPERNOTIFY failed to retrieve url title at %s: %s", url, e)
77 return None
79 async def deliver(self, envelope: Envelope) -> bool:
80 if not envelope.target.mobile_app_ids:
81 _LOGGER.warning("SUPERNOTIFY No targets provided for mobile_push")
82 return False
83 data: dict[str, Any] = envelope.data or {}
84 # TODO: category not passed anywhere
85 category = data.get(ATTR_ACTION_CATEGORY, "general")
86 action_groups = envelope.action_groups
88 _LOGGER.debug("SUPERNOTIFY notify_mobile: %s -> %s", envelope.title, envelope.target.mobile_app_ids)
90 media = envelope.media or {}
91 camera_entity_id = media.get(ATTR_MEDIA_CAMERA_ENTITY_ID)
92 clip_url: str | None = self.hass_api.abs_url(media.get(ATTR_MEDIA_CLIP_URL))
93 snapshot_url: str | None = self.hass_api.abs_url(media.get(ATTR_MEDIA_SNAPSHOT_URL))
94 # options = data.get(CONF_OPTIONS, {})
96 match envelope.priority:
97 case custom_components.supernotify.PRIORITY_CRITICAL:
98 push_priority = "critical"
99 case custom_components.supernotify.PRIORITY_HIGH:
100 push_priority = "time-sensitive"
101 case custom_components.supernotify.PRIORITY_MEDIUM:
102 push_priority = "active"
103 case custom_components.supernotify.PRIORITY_LOW:
104 push_priority = "passive"
105 case _:
106 push_priority = "active"
107 _LOGGER.warning("SUPERNOTIFY Unexpected priority %s", envelope.priority)
109 data.setdefault("actions", [])
110 data.setdefault("push", {})
111 data["push"]["interruption-level"] = push_priority
112 if push_priority == "critical":
113 data["push"].setdefault("sound", {})
114 data["push"]["sound"].setdefault("name", "default")
115 data["push"]["sound"]["critical"] = 1
116 data["push"]["sound"].setdefault("volume", 1.0)
117 else:
118 # critical notifications can't be grouped on iOS
119 category = category or camera_entity_id or "appd"
120 data.setdefault("group", category)
122 if camera_entity_id:
123 data["entity_id"] = camera_entity_id
124 # data['actions'].append({'action':'URI','title':'View Live','uri':'/cameras/%s' % device}
125 if clip_url:
126 data["video"] = clip_url
127 if snapshot_url:
128 data["image"] = snapshot_url
130 data.setdefault("actions", [])
131 for action in envelope.actions:
132 app_url: str | None = self.hass_api.abs_url(action.get(ATTR_ACTION_URL))
133 if app_url:
134 app_url_title = action.get(ATTR_ACTION_URL_TITLE) or self.action_title(app_url) or "Click for Action"
135 action[ATTR_ACTION_URL_TITLE] = app_url_title
136 data["actions"].append(action)
137 if camera_entity_id:
138 data["actions"].append({
139 "action": f"SUPERNOTIFY_SNOOZE_EVERYONE_CAMERA_{camera_entity_id}",
140 "title": f"Snooze camera notifications for {camera_entity_id}",
141 "behavior": "textInput",
142 "textInputButtonTitle": "Minutes to snooze",
143 "textInputPlaceholder": "60",
144 })
145 for group, actions in self.context.mobile_actions.items():
146 if action_groups is None or group in action_groups:
147 data["actions"].extend(actions)
148 if not data["actions"]:
149 del data["actions"]
150 action_data = envelope.core_action_data()
151 action_data[ATTR_DATA] = data
152 hits = 0
153 for mobile_target in envelope.target.mobile_app_ids:
154 full_target = mobile_target if mobile_target.startswith("notify.") else f"notify.{mobile_target}"
155 if await self.call_action(envelope, qualified_action=full_target, action_data=action_data, implied_target=True):
156 hits += 1
157 else:
158 simple_target = (
159 mobile_target if not mobile_target.startswith("notify.") else mobile_target.replace("notify.", "")
160 )
161 _LOGGER.warning("SUPERNOTIFY Failed to send to %s, snoozing for a day", simple_target)
162 if self.people_registry:
163 # somewhat hacky way to tie the mobile device back to a recipient to please the snoozing api
164 for recipient in self.people_registry.people.values():
165 for md in recipient.get(CONF_MOBILE_DEVICES, []):
166 if md.get(CONF_MOBILE_APP_ID) in (simple_target, mobile_target):
167 self.context.snoozer.register_snooze(
168 CommandType.SNOOZE,
169 target_type=QualifiedTargetType.MOBILE,
170 target=simple_target,
171 recipient_type=RecipientType.USER,
172 recipient=recipient[CONF_PERSON],
173 snooze_for=timedelta(days=1),
174 reason="Action Failure",
175 )
176 return hits > 0