Coverage for custom_components/supernotify/transports/html5.py: 100%
122 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +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], or a single
46 int for one vibration. See
47 html5_silent for the exclusivity
48 rule
49 html5_ttl int/dict time-to-live: seconds or an HA
50 duration dict, forwarded as-is
51 html5_data dict extra keys merged into the custom
52 `data` field of the service call
53 (html5_url wins on `url` clashes)
55Notes on the HA `html5.send_message` service schema:
56- The schema is a strict whitelist of first-class fields: unknown keys at
57 the top level fail the whole call. Residual generic data keys are
58 therefore NOT merged into the payload (dropped with a debug log); the
59 `data` custom field, fed by `html5_url` / `html5_data`, is the explicit
60 passthrough for anything else.
61- `title` is REQUIRED by the schema; when the envelope has no title the
62 translated `common.default_title` ("Notification") is used.
63- `lang` is the Home Assistant language, so the browser can choose a
64 suitable font and voice.
65- Targets are `notify.*` entities created by browser push registrations
66 (html5 config entry with VAPID keys). Non-matching targets are dropped
67 with a debug log; with no valid target the delivery is skipped.
68- Expired push subscriptions (410 GONE) are handled by the core, which
69 unregisters the browser and raises: call_action then returns False.
71References:
72- HTML5 push integration: https://www.home-assistant.io/integrations/html5/
74"""
76from __future__ import annotations
78import logging
79from typing import TYPE_CHECKING, Any, ClassVar
81from homeassistant.const import ATTR_ENTITY_ID
82from homeassistant.helpers.typing import ConfigType
84from custom_components.supernotify.common import boolify
85from custom_components.supernotify.const import (
86 ATTR_DATA,
87 RE_NOTIFY_ENTITY_ID,
88 TRANSPORT_HTML5,
89)
90from custom_components.supernotify.model import (
91 DebugTrace,
92 SuppressionReason,
93 TransportConfig,
94 TransportFeature,
95)
96from custom_components.supernotify.options import MEDIA_OPTIONS, OPTION_TARGET_SELECT, 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_VALID_URGENCY = ("low", "normal", "high")
110# SuperNotify priority -> web push urgency
111_URGENCY_BY_PRIORITY = {
112 "critical": "high",
113 "high": "high",
114 "medium": "normal",
115 "low": "low",
116 "minimum": "low",
117}
120class HTML5Transport(Transport):
121 """Notify browsers via the Home Assistant html5 web push integration."""
123 def __init__(self, *args: Any, **kwargs: Any) -> None:
124 super().__init__(*args, **kwargs)
126 name = TRANSPORT_HTML5
127 integration_domain = HA_HTML5_DOMAIN
128 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS]
130 @property
131 def supported_features(self) -> TransportFeature:
132 return (
133 TransportFeature.MESSAGE
134 | TransportFeature.TITLE
135 | TransportFeature.IMAGES
136 | TransportFeature.SNAPSHOT_IMAGE
137 | TransportFeature.ACTIONS
138 )
140 @property
141 def default_config(self) -> TransportConfig:
142 config = super().default_config
143 config.delivery_defaults.action = "html5.send_message"
144 config.delivery_defaults.options = {
145 OPTION_TARGET_SELECT: [RE_NOTIFY_ENTITY_ID],
146 }
147 return config
149 @property
150 def unique_target_categories(self) -> list[str | TargetEntityCategory]:
151 # a notify.* entity's registered platform identifies it as this integration's own,
152 # unlike the generic notify_entity transport which has no such distinction
153 return [TargetEntityCategory(domain="notify", platform=HA_HTML5_DOMAIN)]
155 def validate_action(self, action: str | None) -> bool:
156 """Validate that action is the html5 send_message service."""
157 return action == "html5.send_message"
159 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
160 if hass_api.find_config_entry_data(HA_HTML5_DOMAIN) is None:
161 return False
162 # integration installed but no browser has registered a push subscription yet
163 return bool(hass_api.entity_ids_for_platform("notify", HA_HTML5_DOMAIN))
165 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
166 return {self.name: {}}
168 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
169 _LOGGER.debug("SUPERNOTIFY html5 %s", envelope.message)
171 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
173 # Pop html5-specific data keys
174 urgency_override = raw_data.pop("html5_urgency", None)
175 tag = raw_data.pop("html5_tag", None)
176 actions = raw_data.pop("html5_actions", None)
177 attach_image = boolify(raw_data.pop("html5_attach_image", False), default=False)
178 icon = raw_data.pop("html5_icon", None)
179 badge = raw_data.pop("html5_badge", None)
180 click_url = raw_data.pop("html5_url", None)
181 require_interaction_raw = raw_data.pop("html5_require_interaction", None)
182 renotify_raw = raw_data.pop("html5_renotify", None)
183 silent_raw = raw_data.pop("html5_silent", None)
184 vibrate = raw_data.pop("html5_vibrate", None)
185 ttl = raw_data.pop("html5_ttl", None)
186 custom_data = raw_data.pop("html5_data", None)
188 # Resolve and pre-validate notify entity targets
189 targets = envelope.target.resolved_targets() if envelope.target else []
190 if not targets:
191 _LOGGER.warning("SUPERNOTIFY html5: no valid targets (expected notify.* entities)")
192 envelope.skipped = 1
193 envelope.skip_reason = SuppressionReason.NO_TARGET
194 return False
196 # Resolve urgency: explicit valid override, else mapped from priority
197 urgency = _URGENCY_BY_PRIORITY.get(envelope.priority or "medium", "normal")
198 if urgency_override is not None:
199 candidate = str(urgency_override).lower()
200 if candidate in _VALID_URGENCY:
201 urgency = candidate
202 else:
203 _LOGGER.warning(
204 "SUPERNOTIFY html5: invalid html5_urgency %r (valid: %s), using '%s'",
205 urgency_override,
206 _VALID_URGENCY,
207 urgency,
208 )
210 # The service schema declares silent and vibrate as mutually
211 # exclusive (vol.Exclusive shares the "silent_xor_vibrate" group):
212 # sending both keys fails the whole call, whatever their values
213 if silent_raw is not None and vibrate is not None:
214 if boolify(silent_raw, default=False):
215 _LOGGER.warning("SUPERNOTIFY html5: html5_silent and html5_vibrate are mutually exclusive, dropping vibrate")
216 vibrate = None
217 else:
218 _LOGGER.warning(
219 "SUPERNOTIFY html5: html5_silent and html5_vibrate are mutually exclusive, dropping falsy silent"
220 )
221 silent_raw = None
223 # Build the payload: title is REQUIRED by the service schema
224 action_data: dict[str, Any] = {
225 "title": envelope.title or await self.common_string("default_title"),
226 "message": envelope.message or "",
227 "urgency": urgency,
228 }
229 if isinstance(self.hass_api.language, str) and self.hass_api.language:
230 action_data["lang"] = self.hass_api.language
231 if icon:
232 action_data["icon"] = str(icon)
233 if badge:
234 action_data["badge"] = str(badge)
235 if tag:
236 action_data["tag"] = str(tag)
237 if actions is not None:
238 if isinstance(actions, list):
239 action_data["actions"] = actions
240 else:
241 _LOGGER.warning("SUPERNOTIFY html5: html5_actions must be a list of dicts, dropping %r", actions)
242 if renotify_raw is not None:
243 action_data["renotify"] = boolify(renotify_raw, default=False)
244 if silent_raw is not None:
245 action_data["silent"] = boolify(silent_raw, default=False)
246 if require_interaction_raw is not None:
247 action_data["require_interaction"] = boolify(require_interaction_raw, default=False)
248 if vibrate is not None:
249 if isinstance(vibrate, int) and not isinstance(vibrate, bool):
250 vibrate = [vibrate]
251 if isinstance(vibrate, list):
252 action_data["vibrate"] = vibrate
253 else:
254 _LOGGER.warning("SUPERNOTIFY html5: html5_vibrate must be a list of ints, dropping %r", vibrate)
255 if ttl is not None:
256 action_data["ttl"] = ttl
258 # Attach camera snapshot as browser-reachable URL (never a local path)
259 if attach_image:
260 image_url = await self.image_url(envelope)
261 if image_url:
262 action_data["image"] = str(image_url)
263 else:
264 _LOGGER.debug("SUPERNOTIFY html5: no image URL available, sending without image")
266 # Custom `data` field: the only passthrough the strict schema allows.
267 # html5_data is merged first so the explicit html5_url wins on `url`.
268 data_field: dict[str, Any] = {}
269 if isinstance(custom_data, dict):
270 data_field.update(custom_data)
271 elif custom_data is not None:
272 _LOGGER.warning("SUPERNOTIFY html5: html5_data must be a dict, dropping %r", custom_data)
273 if click_url:
274 data_field["url"] = str(click_url)
275 elif "url" not in data_field and (notification_uri := envelope.notification_uri(absolute=True)):
276 data_field["url"] = notification_uri
277 if data_field:
278 action_data[ATTR_DATA] = data_field
280 # Residual generic keys are NOT merged: the service schema is a
281 # strict whitelist and any extra top-level key fails the whole call.
282 if raw_data:
283 _LOGGER.debug(
284 "SUPERNOTIFY html5: dropping data keys not supported by the strict service schema: %s",
285 sorted(raw_data),
286 )
288 target_data = {ATTR_ENTITY_ID: targets}
289 return await self.call_action(envelope, action_data=action_data, target_data=target_data)