Coverage for custom_components/supernotify/transports/html5.py: 98%
136 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
1"""HTML5 browser push transport for SuperNotify.
3Sends web push notifications to browsers registered with Home Assistant's
4`html5` integration, calling the modern `html5.send_message` entity service
5(one `notify.*` entity per registered browser). The legacy `notify.html5`
6platform is intentionally NOT used: its `ttl` and `priority` parameters are
7read from send_message kwargs that the notify service never populates, so
8urgency would silently always be "normal".
10Supported data keys (all optional):
11 html5_urgency str override web push urgency, one of
12 low | normal | high. Default is
13 mapped from the SuperNotify
14 priority: critical/high -> high,
15 medium -> normal,
16 low/minimum -> low
17 html5_tag str notification tag: notifications
18 sharing a tag replace each other
19 html5_actions list[dict] action buttons, each
20 {action, title, icon}. Clicks fire
21 `html5_notification.clicked`
22 events with the `action` value
23 html5_attach_image bool attach camera snapshot as `image`
24 URL (default: False). Uses the
25 shared media pipeline; the URL must
26 be reachable by the browser, so an
27 HTTPS external_url (or HA Cloud)
28 is usually required
29 html5_icon str icon URL
30 html5_badge str badge URL (Android status bar)
31 html5_url str URL opened when the notification
32 is clicked (sent as `data.url`)
33 html5_require_interaction bool keep the notification on screen
34 until the user interacts with it
35 html5_renotify bool alert again when a new notification
36 replaces an existing tag
37 html5_silent bool suppress sound/vibration. Mutually
38 exclusive with html5_vibrate in the
39 service schema (vol.Exclusive): when
40 both are supplied, a truthy silent
41 wins and vibrate is dropped, a falsy
42 silent is dropped in favour of
43 vibrate (warning either way)
44 html5_vibrate list[int] vibration pattern in milliseconds,
45 e.g. [200, 100, 200]. See
46 html5_silent for the exclusivity
47 rule
48 html5_ttl int/dict time-to-live: seconds or an HA
49 duration dict, forwarded as-is
50 html5_data dict extra keys merged into the custom
51 `data` field of the service call
52 (html5_url wins on `url` clashes)
54Notes on the HA `html5.send_message` service schema:
55- The schema is a strict whitelist of first-class fields: unknown keys at
56 the top level fail the whole call. Residual generic data keys are
57 therefore NOT merged into the payload (dropped with a debug log); the
58 `data` custom field, fed by `html5_url` / `html5_data`, is the explicit
59 passthrough for anything else.
60- `title` is REQUIRED by the schema; when the envelope has no title the
61 HA default "Home Assistant" is used.
62- Targets are `notify.*` entities created by browser push registrations
63 (html5 config entry with VAPID keys). Non-matching targets are dropped
64 with a debug log; no valid target fails the delivery.
65- Expired push subscriptions (410 GONE) are handled by the core, which
66 unregisters the browser and raises: call_action then returns False.
68References:
69- HTML5 push integration: https://www.home-assistant.io/integrations/html5/
71"""
73from __future__ import annotations
75import logging
76from typing import TYPE_CHECKING, Any, ClassVar
78from homeassistant.const import ATTR_ENTITY_ID
79from homeassistant.helpers.typing import ConfigType
81from custom_components.supernotify.common import boolify
82from custom_components.supernotify.const import (
83 ATTR_DATA,
84 ATTR_MEDIA_SNAPSHOT_URL,
85 INCLUSION_DEFAULT,
86 RE_NOTIFY_ENTITY_ID,
87 TRANSPORT_HTML5,
88)
89from custom_components.supernotify.model import (
90 DebugTrace,
91 SelectionRank,
92 TargetRequired,
93 TransportConfig,
94 TransportFeature,
95)
96from custom_components.supernotify.options import MEDIA_OPTIONS, OPTION_TARGET_SELECT, OPTION_UNIQUE_TARGETS, DeliveryOption
97from custom_components.supernotify.target import TargetEntityCategory
98from custom_components.supernotify.transport import Transport
100if TYPE_CHECKING:
101 from custom_components.supernotify.envelope import Envelope
102 from custom_components.supernotify.hass_api import HomeAssistantAPI
104HA_HTML5_DOMAIN = "html5"
106_LOGGER = logging.getLogger(__name__)
108# HA schema default for the required title field
109_DEFAULT_TITLE = "Home Assistant"
111_VALID_URGENCY = ("low", "normal", "high")
113# SuperNotify priority -> web push urgency
114_URGENCY_BY_PRIORITY = {
115 "critical": "high",
116 "high": "high",
117 "medium": "normal",
118 "low": "low",
119 "minimum": "low",
120}
123class HTML5Transport(Transport):
124 """Notify browsers via the Home Assistant html5 web push integration."""
126 def __init__(self, *args: Any, **kwargs: Any) -> None:
127 super().__init__(*args, **kwargs)
129 name = TRANSPORT_HTML5
130 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS]
132 @property
133 def supported_features(self) -> TransportFeature:
134 return (
135 TransportFeature.MESSAGE
136 | TransportFeature.TITLE
137 | TransportFeature.IMAGES
138 | TransportFeature.SNAPSHOT_IMAGE
139 | TransportFeature.ACTIONS
140 )
142 @property
143 def default_config(self) -> TransportConfig:
144 config = TransportConfig()
145 config.delivery_defaults.action = "html5.send_message"
146 config.delivery_defaults.inclusion = self.inclusion_mode
147 config.delivery_defaults.target_required = TargetRequired.ALWAYS
148 config.delivery_defaults.selection_rank = SelectionRank.FIRST
149 config.delivery_defaults.options = {
150 OPTION_UNIQUE_TARGETS: True, # stop Notify Entity also trying to handle these
151 OPTION_TARGET_SELECT: [RE_NOTIFY_ENTITY_ID],
152 }
153 return config
155 @property
156 def target_categories(self) -> list[str | TargetEntityCategory]:
157 # a notify.* entity's registered platform identifies it as this integration's own,
158 # unlike the generic notify_entity transport which has no such distinction
159 return [TargetEntityCategory(domain="notify", platform=HA_HTML5_DOMAIN)]
161 @property
162 def inclusion_mode(self) -> list[str]:
163 # a browser's notify.* entity is unambiguously this integration's own (matched by
164 # platform, not just a loose notify.* shape), so it's reasonable to fire by default
165 return [INCLUSION_DEFAULT]
167 def validate_action(self, action: str | None) -> bool:
168 """Validate that action is the html5 send_message service."""
169 return action == "html5.send_message"
171 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
172 if hass_api.find_config_entry_data(HA_HTML5_DOMAIN) is None:
173 return False
174 # integration installed but no browser has registered a push subscription yet
175 return bool(hass_api.entity_ids_for_platform("notify", HA_HTML5_DOMAIN))
177 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
178 return {self.name: {}}
180 async def _resolve_image_url(self, envelope: Envelope) -> str | None:
181 """Resolve a browser-reachable snapshot URL.
183 Order of resolution:
184 1. snapshot URL already in envelope media, absolutised
185 2. envelope.grab_image() + media_storage.object_url() (shared
186 media pipeline; never a local path)
187 3. None
188 """
189 snapshot_url = envelope.media.get(ATTR_MEDIA_SNAPSHOT_URL) if envelope.media else None
190 if snapshot_url:
191 return self.hass_api.abs_url(snapshot_url)
193 image_path = None
194 try:
195 image_path = await envelope.grab_image()
196 except Exception as e:
197 _LOGGER.warning("SUPERNOTIFY html5: failed to grab image: %s", e)
198 if image_path:
199 try:
200 return await self.context.media_storage.object_url(image_path)
201 except Exception as e:
202 _LOGGER.debug("SUPERNOTIFY html5: object_url failed for %s: %s", image_path, e)
203 return None
205 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
206 _LOGGER.debug("SUPERNOTIFY html5 %s", envelope.message)
208 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
210 # Pop html5-specific data keys
211 urgency_override = raw_data.pop("html5_urgency", None)
212 tag = raw_data.pop("html5_tag", None)
213 actions = raw_data.pop("html5_actions", None)
214 attach_image = boolify(raw_data.pop("html5_attach_image", False), default=False)
215 icon = raw_data.pop("html5_icon", None)
216 badge = raw_data.pop("html5_badge", None)
217 click_url = raw_data.pop("html5_url", None)
218 require_interaction_raw = raw_data.pop("html5_require_interaction", None)
219 renotify_raw = raw_data.pop("html5_renotify", None)
220 silent_raw = raw_data.pop("html5_silent", None)
221 vibrate = raw_data.pop("html5_vibrate", None)
222 ttl = raw_data.pop("html5_ttl", None)
223 custom_data = raw_data.pop("html5_data", None)
225 # Resolve and pre-validate notify entity targets
226 targets = envelope.target.resolved_targets() if envelope.target else []
227 if not targets:
228 _LOGGER.warning("SUPERNOTIFY html5: no valid targets (expected notify.* entities)")
229 self.record_error("no valid html5 notify entity targets", "deliver")
230 return False
232 # Resolve urgency: explicit valid override, else mapped from priority
233 urgency = _URGENCY_BY_PRIORITY.get(envelope.priority or "medium", "normal")
234 if urgency_override is not None:
235 candidate = str(urgency_override).lower()
236 if candidate in _VALID_URGENCY:
237 urgency = candidate
238 else:
239 _LOGGER.warning(
240 "SUPERNOTIFY html5: invalid html5_urgency %r (valid: %s), using '%s'",
241 urgency_override,
242 _VALID_URGENCY,
243 urgency,
244 )
246 # The service schema declares silent and vibrate as mutually
247 # exclusive (vol.Exclusive shares the "silent_xor_vibrate" group):
248 # sending both keys fails the whole call, whatever their values
249 if silent_raw is not None and vibrate is not None:
250 if boolify(silent_raw, default=False):
251 _LOGGER.warning("SUPERNOTIFY html5: html5_silent and html5_vibrate are mutually exclusive, dropping vibrate")
252 vibrate = None
253 else:
254 _LOGGER.warning(
255 "SUPERNOTIFY html5: html5_silent and html5_vibrate are mutually exclusive, dropping falsy silent"
256 )
257 silent_raw = None
259 # Build the payload: title is REQUIRED by the service schema
260 action_data: dict[str, Any] = {
261 "title": envelope.title or _DEFAULT_TITLE,
262 "message": envelope.message or "",
263 "urgency": urgency,
264 }
265 if icon:
266 action_data["icon"] = str(icon)
267 if badge:
268 action_data["badge"] = str(badge)
269 if tag:
270 action_data["tag"] = str(tag)
271 if actions is not None:
272 if isinstance(actions, list):
273 action_data["actions"] = actions
274 else:
275 _LOGGER.warning("SUPERNOTIFY html5: html5_actions must be a list of dicts, dropping %r", actions)
276 if renotify_raw is not None:
277 action_data["renotify"] = boolify(renotify_raw, default=False)
278 if silent_raw is not None:
279 action_data["silent"] = boolify(silent_raw, default=False)
280 if require_interaction_raw is not None:
281 action_data["require_interaction"] = boolify(require_interaction_raw, default=False)
282 if vibrate is not None:
283 if isinstance(vibrate, list):
284 action_data["vibrate"] = vibrate
285 else:
286 _LOGGER.warning("SUPERNOTIFY html5: html5_vibrate must be a list of ints, dropping %r", vibrate)
287 if ttl is not None:
288 action_data["ttl"] = ttl
290 # Attach camera snapshot as browser-reachable URL (never a local path)
291 if attach_image:
292 image_url = await self._resolve_image_url(envelope)
293 if image_url:
294 action_data["image"] = str(image_url)
295 else:
296 _LOGGER.debug("SUPERNOTIFY html5: no image URL available, sending without image")
298 # Custom `data` field: the only passthrough the strict schema allows.
299 # html5_data is merged first so the explicit html5_url wins on `url`.
300 data_field: dict[str, Any] = {}
301 if isinstance(custom_data, dict):
302 data_field.update(custom_data)
303 elif custom_data is not None:
304 _LOGGER.warning("SUPERNOTIFY html5: html5_data must be a dict, dropping %r", custom_data)
305 if click_url:
306 data_field["url"] = str(click_url)
307 if data_field:
308 action_data[ATTR_DATA] = data_field
310 # Residual generic keys are NOT merged: the service schema is a
311 # strict whitelist and any extra top-level key fails the whole call.
312 if raw_data:
313 _LOGGER.debug(
314 "SUPERNOTIFY html5: dropping data keys not supported by the strict service schema: %s",
315 sorted(raw_data),
316 )
318 target_data = {ATTR_ENTITY_ID: targets}
319 return await self.call_action(envelope, action_data=action_data, target_data=target_data)