Coverage for custom_components/supernotify/transports/html5.py: 98%

136 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 14:29 +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]. 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) 

53 

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. 

67 

68References: 

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

70 

71""" 

72 

73from __future__ import annotations 

74 

75import logging 

76from typing import TYPE_CHECKING, Any, ClassVar 

77 

78from homeassistant.const import ATTR_ENTITY_ID 

79from homeassistant.helpers.typing import ConfigType 

80 

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 

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# HA schema default for the required title field 

109_DEFAULT_TITLE = "Home Assistant" 

110 

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

112 

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} 

121 

122 

123class HTML5Transport(Transport): 

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

125 

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

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

128 

129 name = TRANSPORT_HTML5 

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

131 

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 ) 

141 

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 

154 

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

160 

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] 

166 

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" 

170 

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

176 

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

178 return {self.name: {}} 

179 

180 async def _resolve_image_url(self, envelope: Envelope) -> str | None: 

181 """Resolve a browser-reachable snapshot URL. 

182 

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) 

192 

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 

204 

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

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

207 

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

209 

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) 

224 

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 

231 

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 ) 

245 

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 

258 

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 

289 

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

297 

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 

309 

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 ) 

317 

318 target_data = {ATTR_ENTITY_ID: targets} 

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