Coverage for custom_components/supernotify/actions.py: 97%

173 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 14:29 +0000

1"""Supernotify service, extending BaseNotificationService""" 

2 

3from __future__ import annotations 

4 

5import datetime as dt 

6import logging 

7from typing import TYPE_CHECKING, Any, Final 

8 

9import voluptuous as vol 

10from homeassistant.const import ( 

11 CONF_TARGET, 

12) 

13from homeassistant.core import ( 

14 HomeAssistant, 

15 ServiceCall, 

16 SupportsResponse, 

17 callback, 

18) 

19from homeassistant.exceptions import ServiceValidationError 

20from homeassistant.helpers.service import async_set_service_schema 

21from homeassistant.loader import async_get_integration 

22from homeassistant.util.yaml import load_yaml_dict 

23 

24from . import DOMAIN 

25from .archive import ARCHIVE_PURGE_MIN_INTERVAL 

26from .common import ensure_list 

27from .const import ( 

28 ATTR_CUSTOM_TARGET, 

29 ATTR_DATA, 

30 ATTR_DELIVERY, 

31 ATTR_DELIVERY_CONTROL, 

32 ATTR_DELIVERY_SELECTION, 

33 ATTR_EXTRA_DATA, 

34 ATTR_MEDIA, 

35 ATTR_MEDIA_CAMERA_ENTITY_ID, 

36 ATTR_MEDIA_CLIP_URL, 

37 ATTR_MEDIA_SNAPSHOT_URL, 

38 ATTR_SCENARIOS_APPLY, 

39 ATTR_SCENARIOS_CONSTRAIN, 

40 ATTR_SCENARIOS_REQUIRE, 

41 CONF_ACTION_GROUPS, 

42 CONF_ACTIONS, 

43 CONF_ARCHIVE, 

44 CONF_CAMERAS, 

45 CONF_DELIVERY, 

46 CONF_DUPE_CHECK, 

47 CONF_HOUSEKEEPING, 

48 CONF_LINKS, 

49 CONF_MEDIA_PATH, 

50 CONF_MESSAGE, 

51 CONF_MOBILE_DISCOVERY, 

52 CONF_RECIPIENTS, 

53 CONF_RECIPIENTS_DISCOVERY, 

54 CONF_SCENARIO_CONTROL, 

55 CONF_SCENARIOS, 

56 CONF_SNOOZE, 

57 CONF_TEMPLATE_PATH, 

58 CONF_TITLE, 

59 CONF_TRANSPORTS, 

60 DELIVERY_SELECTION_EXPLICIT, 

61 OVERRIDE_KINDS, 

62) 

63from .engine import SupernotifyEngine 

64from .schema import ACTION_DATA_FIELDS, NOTIFY_ACTION_SCHEMA 

65from .target import Target 

66 

67if TYPE_CHECKING: 

68 from homeassistant.helpers.typing import ConfigType 

69 

70_LOGGER = logging.getLogger(__name__) 

71 

72 

73def lift_legacy_nested_data(data: dict[str, Any]) -> dict[str, Any]: 

74 """Migrate a notify.supernotify-shaped payload sent to supernotify.notify. 

75 

76 notify.supernotify carries Supernotify's own fields inside the call's `data:`, where 

77 supernotify.notify takes them at top level and keeps `data:` for the target service. Renaming 

78 the action on an old automation therefore leaves e.g. `message_html` and `priority` stranded 

79 in pass-through data. If the nested `data:` holds any Supernotify action field (including a 

80 further nested `data:`), treat it as the legacy block: lift Supernotify's fields to top level, 

81 keep the rest as pass-through. 

82 

83 `extra_data`, the preferred home for pass-through data that legitimately reuses Supernotify 

84 field names (e.g. a mobile_app push's own `priority`), is never inspected or changed here. 

85 """ 

86 data = dict(data) 

87 nested = data.get(ATTR_DATA) 

88 if isinstance(nested, dict) and not ACTION_DATA_FIELDS.isdisjoint(nested): 

89 _LOGGER.warning( 

90 "SUPERNOTIFY supernotify.notify has Supernotify fields (%s) inside `data:`, which looks like an automation " 

91 "changed from notify.supernotify. Treating as top-level fields, but move them out of `data:` to silence this, " 

92 "or use `extra_data:` if they are meant for the target service", 

93 ", ".join(sorted(ACTION_DATA_FIELDS.intersection(nested))), 

94 ) 

95 lifted = {k: v for k, v in nested.items() if k in ACTION_DATA_FIELDS and k != ATTR_DATA} 

96 passthrough = {k: v for k, v in nested.items() if k not in ACTION_DATA_FIELDS} 

97 passthrough.update(nested.get(ATTR_DATA) or {}) 

98 # explicit top-level values win, but the schema fills empty defaults (action_groups: [] etc) 

99 # for absent ones, so an empty top-level value must not shadow a lifted one 

100 data = lifted | {k: v for k, v in data.items() if k != ATTR_DATA and (v or k not in lifted)} 

101 if passthrough: 

102 data[ATTR_DATA] = passthrough 

103 return data 

104 

105 

106def merge_delivery_fields(data: dict[str, Any]) -> dict[str, Any]: 

107 """Fold supernotify.notify's delivery dropdown and free-form Delivery Control into one `delivery`, 

108 written as it could have been in YAML. 

109 

110 Names alone stay a list, restricting to those deliveries. Once Delivery Control has a mapping, the 

111 two become one mapping, and a delivery in both takes the form it has in Delivery Control. A mapping 

112 on its own only tunes deliveries without restricting them, so when names were also picked from the 

113 dropdown - which the UI pre-fills with the implicit deliveries, to add to or take from - the 

114 selection is made explicit, unless the call set it, keeping the dropdown's meaning of "only these". 

115 """ 

116 data = dict(data) 

117 control: Any = data.pop(ATTR_DELIVERY_CONTROL, None) 

118 picked: Any = data.get(ATTR_DELIVERY) 

119 if not control: 

120 return data 

121 if not picked: 

122 data[ATTR_DELIVERY] = control 

123 return data 

124 if isinstance(control, dict) or isinstance(picked, dict): 

125 merged: dict[str, Any] = dict(picked) if isinstance(picked, dict) else dict.fromkeys(ensure_list(picked)) 

126 merged.update(control if isinstance(control, dict) else dict.fromkeys(ensure_list(control))) 

127 data[ATTR_DELIVERY] = merged 

128 if not isinstance(picked, dict): 

129 data.setdefault(ATTR_DELIVERY_SELECTION, DELIVERY_SELECTION_EXPLICIT) 

130 else: 

131 data[ATTR_DELIVERY] = list(dict.fromkeys([*ensure_list(picked), *ensure_list(control)])) 

132 return data 

133 

134 

135ACTION_NAMES: Final[tuple[str, ...]] = ( 

136 "notify", 

137 "enquire_archive", 

138 "enquire_configuration", 

139 "enquire_implicit_deliveries", 

140 "enquire_deliveries_by_scenario", 

141 "enquire_last_notification", 

142 "enquire_active_scenarios", 

143 "enquire_scenarios", 

144 "enquire_occupancy", 

145 "enquire_recipients", 

146 "enquire_snoozes", 

147 "clear_snoozes", 

148 "purge_archive", 

149 "purge_media", 

150 "refresh_entities", 

151 "reset_overrides", 

152) 

153 

154ATTR_KIND: Final[str] = "kind" 

155RESET_OVERRIDES_SCHEMA: Final = vol.Schema({vol.Optional(ATTR_KIND): vol.In(OVERRIDE_KINDS)}) 

156 

157 

158@callback 

159def async_register_engine_actions(hass: HomeAssistant, engine: SupernotifyEngine, config: ConfigType) -> None: 

160 """Register the domain-scoped supplemental/debugging/admin services. 

161 

162 Shared by the legacy YAML platform (async_get_service, below) and the config-entry setup 

163 (async_setup_entry in __init__.py), so both setup paths expose the same services. These 

164 are DOMAIN-scoped, not per config entry, so registration is guarded against being run 

165 twice - see ACTION_NAMES/async_unregister_engine_actions for the 

166 matching teardown. 

167 

168 enquire_configuration closes over the raw config dict rather than `service`, because 

169 several of the fields it reports (delivery/transport/archive/dupe_check config, the full 

170 set of configured scenarios/recipients) are either private on the registries after 

171 initialize() or lossily reduced to derived values there - so `service` alone can't 

172 reconstruct them. 

173 """ 

174 if hass.services.has_service(DOMAIN, "enquire_configuration"): 

175 return 

176 

177 async def action_notify(call: ServiceCall) -> None: 

178 """supernotify.notify - an alternative to notify.supernotify with each option that would 

179 otherwise be buried in the generic `data:` field promoted to its own schema-checked, 

180 selector-driven field (see NOTIFY_ACTION_SCHEMA/services.yaml). Also propagates the 

181 calling action's Context through to deliveries, same as the SuperNotificationService override 

182 of _async_notify_message_service does for notify.supernotify/notify.<target>. 

183 """ 

184 data = merge_delivery_fields(lift_legacy_nested_data(dict(call.data))) 

185 # extra_data is what Notification knows as `data`, and wins over any same-named key in a legacy `data` 

186 if extra_data := data.pop(ATTR_EXTRA_DATA, None): 

187 data[ATTR_DATA] = {**(data.get(ATTR_DATA) or {}), **extra_data} 

188 message = data.pop(CONF_MESSAGE) 

189 title = data.pop(CONF_TITLE, None) 

190 target = data.pop(CONF_TARGET, None) 

191 # custom_target holds identifiers the target selector can't produce (e-mail addresses, 

192 # phone numbers, Slack ids etc) - merge into target here so nothing downstream needs to 

193 # know this field exists 

194 custom_target = ensure_list(data.pop(ATTR_CUSTOM_TARGET, None)) 

195 if custom_target: 

196 if isinstance(target, dict): 

197 merged_target = dict(target) 

198 for category, values in Target(custom_target).targets.items(): 

199 merged_target[category] = [*ensure_list(merged_target.get(category)), *values] 

200 target = merged_target 

201 else: 

202 target = [*ensure_list(target), *custom_target] 

203 # camera_entity_id/clip_url/snapshot_url are promoted top-level fields for this action's 

204 # UI - fold them into media, overriding any same-named key already nested in media: itself 

205 promoted_media = { 

206 key: data.pop(key) 

207 for key in (ATTR_MEDIA_CAMERA_ENTITY_ID, ATTR_MEDIA_CLIP_URL, ATTR_MEDIA_SNAPSHOT_URL) 

208 if key in data 

209 } 

210 if promoted_media: 

211 media = dict(data.get(ATTR_MEDIA) or {}) 

212 media.update(promoted_media) 

213 data[ATTR_MEDIA] = media 

214 await engine.async_send_message(message, title=title, target=target, data=data, context=call.context) 

215 

216 def supplemental_action_enquire_configuration(_call: ServiceCall) -> dict[str, Any]: 

217 return { 

218 CONF_DELIVERY: config.get(CONF_DELIVERY, {}), 

219 CONF_LINKS: config.get(CONF_LINKS, ()), 

220 CONF_TEMPLATE_PATH: config.get(CONF_TEMPLATE_PATH, None), 

221 CONF_MEDIA_PATH: config.get(CONF_MEDIA_PATH, None), 

222 CONF_ARCHIVE: config.get(CONF_ARCHIVE, {}), 

223 CONF_MOBILE_DISCOVERY: config.get(CONF_MOBILE_DISCOVERY, ()), 

224 CONF_RECIPIENTS_DISCOVERY: config.get(CONF_RECIPIENTS_DISCOVERY, ()), 

225 CONF_RECIPIENTS: config.get(CONF_RECIPIENTS, ()), 

226 CONF_ACTIONS: config.get(CONF_ACTIONS, {}), 

227 CONF_HOUSEKEEPING: config.get(CONF_HOUSEKEEPING, {}), 

228 CONF_ACTION_GROUPS: config.get(CONF_ACTION_GROUPS, {}), 

229 CONF_SCENARIOS: list(config.get(CONF_SCENARIOS, {}).keys()), 

230 CONF_SCENARIO_CONTROL: config.get(CONF_SCENARIO_CONTROL, {}), 

231 CONF_TRANSPORTS: config.get(CONF_TRANSPORTS, {}), 

232 CONF_CAMERAS: config.get(CONF_CAMERAS, {}), 

233 CONF_DUPE_CHECK: config.get(CONF_DUPE_CHECK, {}), 

234 CONF_SNOOZE: config.get(CONF_SNOOZE, {}), 

235 } 

236 

237 @callback 

238 def supplemental_action_refresh_entities(_call: ServiceCall) -> None: 

239 # a callback, so run in the event loop - it writes entity state 

240 engine.refresh_entities() 

241 

242 @callback 

243 def supplemental_action_reset_overrides(call: ServiceCall) -> dict[str, Any]: 

244 # a callback, so run in the event loop - it writes entity state 

245 kind: str | None = call.data.get(ATTR_KIND) 

246 return {"reset": engine.reset_overrides((kind,) if kind else OVERRIDE_KINDS)} 

247 

248 def supplemental_action_enquire_implicit_deliveries(_call: ServiceCall) -> dict[str, Any]: 

249 return engine.enquire_implicit_deliveries() 

250 

251 def supplemental_action_enquire_deliveries_by_scenario(_call: ServiceCall) -> dict[str, Any]: 

252 return engine.enquire_deliveries_by_scenario() 

253 

254 def supplemental_action_enquire_last_notification(call: ServiceCall) -> dict[str, Any]: 

255 diagnostics = call.data.get("diagnostics", False) 

256 return engine.last_notification.contents(diagnostics=diagnostics) if engine.last_notification else {} 

257 

258 async def supplemental_action_enquire_active_scenarios(call: ServiceCall) -> dict[str, Any]: 

259 trace = call.data.get("trace", False) 

260 result: dict[str, Any] = {"scenarios": await engine.enquire_active_scenarios()} 

261 if trace: 

262 result["trace"] = await engine.trace_active_scenarios() 

263 return result 

264 

265 def supplemental_action_enquire_scenarios(_call: ServiceCall) -> dict[str, Any]: 

266 return {"scenarios": engine.enquire_scenarios()} 

267 

268 async def supplemental_action_enquire_occupancy(_call: ServiceCall) -> dict[str, Any]: 

269 return {"scenarios": await engine.enquire_occupancy()} 

270 

271 def supplemental_action_enquire_snoozes(_call: ServiceCall) -> dict[str, Any]: 

272 return {"snoozes": engine.enquire_snoozes()} 

273 

274 def supplemental_action_clear_snoozes(_call: ServiceCall) -> dict[str, Any]: 

275 return {"cleared": engine.clear_snoozes()} 

276 

277 def supplemental_action_enquire_recipients(_call: ServiceCall) -> dict[str, Any]: 

278 return {"recipients": engine.enquire_recipients()} 

279 

280 async def supplemental_action_enquire_archive(call: ServiceCall) -> dict[str, Any]: 

281 archive = engine.context.archive 

282 if not archive.enabled or not archive.archive_directory: 

283 raise ServiceValidationError( 

284 translation_domain=DOMAIN, 

285 translation_key="no_archive_configured", 

286 ) 

287 notification_id: str | None = call.data.get("id") 

288 if notification_id: 

289 entry = await archive.archive_directory.read_entry(notification_id) 

290 if entry is None: 

291 raise ServiceValidationError( 

292 translation_domain=DOMAIN, 

293 translation_key="archive_entry_not_found", 

294 translation_placeholders={"notification_id": notification_id}, 

295 ) 

296 return entry 

297 limit: int = int(call.data.get("limit", 20)) 

298 after_raw: str | None = call.data.get("after") 

299 before_raw: str | None = call.data.get("before") 

300 outcome: str | None = call.data.get("outcome") 

301 after = dt.datetime.fromisoformat(after_raw) if after_raw else None 

302 before = dt.datetime.fromisoformat(before_raw) if before_raw else None 

303 entries = await archive.archive_directory.list_entries(limit=limit, after=after, before=before, outcome=outcome) 

304 return {"notifications": entries, "count": len(entries)} 

305 

306 async def supplemental_action_purge_archive(call: ServiceCall) -> dict[str, Any]: 

307 days = call.data.get("days") 

308 if not engine.context.archive.enabled: 

309 raise ServiceValidationError( 

310 translation_domain=DOMAIN, 

311 translation_key="no_archive_configured", 

312 ) 

313 purged = await engine.context.archive.cleanup(days=days, force=True) 

314 arch_size = await engine.context.archive.size() 

315 return { 

316 "purged": purged, 

317 "remaining": arch_size, 

318 "interval": ARCHIVE_PURGE_MIN_INTERVAL, 

319 "days": engine.context.archive.archive_days if days is None else days, 

320 } 

321 

322 async def supplemental_action_purge_media(call: ServiceCall) -> dict[str, Any]: 

323 days = call.data.get("days") 

324 if not engine.context.media_storage.media_path: 

325 raise ServiceValidationError( 

326 translation_domain=DOMAIN, 

327 translation_key="no_media_storage_configured", 

328 ) 

329 purged = await engine.context.media_storage.cleanup(days=days, force=True) 

330 size = await engine.context.media_storage.size() 

331 return { 

332 "purged": purged, 

333 "remaining": size, 

334 "interval": engine.context.media_storage.purge_minute_interval, 

335 "days": engine.context.media_storage.days if days is None else days, 

336 } 

337 

338 hass.services.async_register( 

339 DOMAIN, 

340 "notify", 

341 action_notify, 

342 schema=NOTIFY_ACTION_SCHEMA, 

343 ) 

344 hass.services.async_register( 

345 DOMAIN, 

346 "enquire_configuration", 

347 supplemental_action_enquire_configuration, 

348 supports_response=SupportsResponse.ONLY, 

349 ) 

350 hass.services.async_register( 

351 DOMAIN, 

352 "enquire_implicit_deliveries", 

353 supplemental_action_enquire_implicit_deliveries, 

354 supports_response=SupportsResponse.ONLY, 

355 ) 

356 hass.services.async_register( 

357 DOMAIN, 

358 "enquire_deliveries_by_scenario", 

359 supplemental_action_enquire_deliveries_by_scenario, 

360 supports_response=SupportsResponse.ONLY, 

361 ) 

362 hass.services.async_register( 

363 DOMAIN, 

364 "enquire_archive", 

365 supplemental_action_enquire_archive, 

366 supports_response=SupportsResponse.ONLY, 

367 ) 

368 hass.services.async_register( 

369 DOMAIN, 

370 "enquire_last_notification", 

371 supplemental_action_enquire_last_notification, 

372 supports_response=SupportsResponse.ONLY, 

373 ) 

374 hass.services.async_register( 

375 DOMAIN, 

376 "enquire_active_scenarios", 

377 supplemental_action_enquire_active_scenarios, 

378 supports_response=SupportsResponse.ONLY, 

379 ) 

380 hass.services.async_register( 

381 DOMAIN, 

382 "enquire_scenarios", 

383 supplemental_action_enquire_scenarios, 

384 supports_response=SupportsResponse.ONLY, 

385 ) 

386 hass.services.async_register( 

387 DOMAIN, 

388 "enquire_occupancy", 

389 supplemental_action_enquire_occupancy, 

390 supports_response=SupportsResponse.ONLY, 

391 ) 

392 hass.services.async_register( 

393 DOMAIN, 

394 "enquire_recipients", 

395 supplemental_action_enquire_recipients, 

396 supports_response=SupportsResponse.ONLY, 

397 ) 

398 hass.services.async_register( 

399 DOMAIN, 

400 "enquire_snoozes", 

401 supplemental_action_enquire_snoozes, 

402 supports_response=SupportsResponse.ONLY, 

403 ) 

404 hass.services.async_register( 

405 DOMAIN, 

406 "clear_snoozes", 

407 supplemental_action_clear_snoozes, 

408 supports_response=SupportsResponse.ONLY, 

409 ) 

410 hass.services.async_register( 

411 DOMAIN, 

412 "purge_archive", 

413 supplemental_action_purge_archive, 

414 supports_response=SupportsResponse.ONLY, 

415 ) 

416 hass.services.async_register( 

417 DOMAIN, 

418 "purge_media", 

419 supplemental_action_purge_media, 

420 supports_response=SupportsResponse.ONLY, 

421 ) 

422 hass.services.async_register( 

423 DOMAIN, 

424 "refresh_entities", 

425 supplemental_action_refresh_entities, 

426 supports_response=SupportsResponse.NONE, 

427 ) 

428 hass.services.async_register( 

429 DOMAIN, 

430 "reset_overrides", 

431 supplemental_action_reset_overrides, 

432 schema=RESET_OVERRIDES_SCHEMA, 

433 supports_response=SupportsResponse.OPTIONAL, 

434 ) 

435 

436 

437async def async_describe_configured_names(hass: HomeAssistant, engine: SupernotifyEngine) -> None: 

438 """Show the deliveries and scenarios configured now in supernotify.notify's description. 

439 

440 services.yaml can only hold a fixed description, so it's copied and set again with the delivery 

441 and scenario fields as dropdowns, still taking a typed name. The delivery field is pre-filled 

442 with the implicit deliveries - what's used when it's left out - to add to or take from - and 

443 offers only deliveries with `default` or `explicit` inclusion, not scenario-only ones. Tuning 

444 deliveries, which needs a mapping, has its own free-form Delivery Control field - see 

445 merge_delivery_fields(). 

446 Names and descriptions still come from the translations, which are looked up by field. 

447 Called on every config entry setup, so a reload picks up changed names. 

448 """ 

449 integration = await async_get_integration(hass, DOMAIN) 

450 services: dict[str, Any] = await hass.async_add_executor_job(load_yaml_dict, str(integration.file_path / "services.yaml")) 

451 notify: dict[str, Any] = services["notify"] 

452 fields: dict[str, Any] = notify["fields"] 

453 if deliveries := list(engine.context.delivery_registry.choosable_deliveries): 

454 fields[ATTR_DELIVERY]["selector"] = {"select": {"options": deliveries, "multiple": True, "custom_value": True}} 

455 fields[ATTR_DELIVERY]["default"] = [d.name for d in engine.context.delivery_registry.implicit_deliveries] 

456 if scenarios := list(engine.context.scenario_registry.scenarios): 

457 for field in (ATTR_SCENARIOS_REQUIRE, ATTR_SCENARIOS_APPLY, ATTR_SCENARIOS_CONSTRAIN): 

458 fields["scenarios"]["fields"][field]["selector"] = { 

459 "select": {"options": scenarios, "multiple": True, "custom_value": True} 

460 } 

461 async_set_service_schema(hass, DOMAIN, "notify", notify) 

462 

463 

464@callback 

465def async_unregister_engine_actions(hass: HomeAssistant) -> None: 

466 """Undo async_register_engine_actions.""" 

467 for name in ACTION_NAMES: 

468 if hass.services.has_service(DOMAIN, name): 

469 hass.services.async_remove(DOMAIN, name)