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

1"""HTML5 browser push transport for SuperNotify. 

2 

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". 

9 

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) 

54 

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. 

70 

71References: 

72- HTML5 push integration: https://www.home-assistant.io/integrations/html5/ 

73 

74""" 

75 

76from __future__ import annotations 

77 

78import logging 

79from typing import TYPE_CHECKING, Any, ClassVar 

80 

81from homeassistant.const import ATTR_ENTITY_ID 

82from homeassistant.helpers.typing import ConfigType 

83 

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 

99 

100if TYPE_CHECKING: 

101 from custom_components.supernotify.envelope import Envelope 

102 from custom_components.supernotify.hass_api import HomeAssistantAPI 

103 

104HA_HTML5_DOMAIN = "html5" 

105 

106_LOGGER = logging.getLogger(__name__) 

107 

108_VALID_URGENCY = ("low", "normal", "high") 

109 

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} 

118 

119 

120class HTML5Transport(Transport): 

121 """Notify browsers via the Home Assistant html5 web push integration.""" 

122 

123 def __init__(self, *args: Any, **kwargs: Any) -> None: 

124 super().__init__(*args, **kwargs) 

125 

126 name = TRANSPORT_HTML5 

127 integration_domain = HA_HTML5_DOMAIN 

128 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS] 

129 

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 ) 

139 

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 

148 

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)] 

154 

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" 

158 

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)) 

164 

165 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]: 

166 return {self.name: {}} 

167 

168 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: 

169 _LOGGER.debug("SUPERNOTIFY html5 %s", envelope.message) 

170 

171 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {} 

172 

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) 

187 

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 

195 

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 ) 

209 

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 

222 

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 

257 

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") 

265 

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 

279 

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 ) 

287 

288 target_data = {ATTR_ENTITY_ID: targets} 

289 return await self.call_action(envelope, action_data=action_data, target_data=target_data)