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

229 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-08 17:42 +0000

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

2 

3from __future__ import annotations 

4 

5import datetime as dt 

6import logging 

7import sys 

8from functools import partial 

9from typing import TYPE_CHECKING, Any, Final 

10 

11from homeassistant.const import ( 

12 CONF_TARGET, 

13) 

14from homeassistant.core import ( 

15 HomeAssistant, 

16 ServiceCall, 

17 SupportsResponse, 

18 callback, 

19) 

20from homeassistant.exceptions import ServiceValidationError 

21from homeassistant.helpers import config_validation as cv 

22from homeassistant.helpers.service import async_set_service_schema 

23from homeassistant.helpers.template import Template 

24from homeassistant.loader import async_get_integration 

25from homeassistant.util import dt as dt_util 

26from homeassistant.util.yaml import load_yaml_dict 

27 

28from . import DOMAIN 

29from .archive import summarize_by_day, summarize_notification 

30from .common import ensure_list 

31from .compat import vol 

32from .const import ( 

33 ATTR_CUSTOM_TARGET, 

34 ATTR_DATA, 

35 ATTR_DELIVERY, 

36 ATTR_DELIVERY_CONTROL, 

37 ATTR_DELIVERY_SELECTION, 

38 ATTR_EXTRA_DATA, 

39 ATTR_MEDIA, 

40 ATTR_MEDIA_CAMERA_ENTITY_ID, 

41 ATTR_MEDIA_CLIP_URL, 

42 ATTR_MEDIA_SNAPSHOT_URL, 

43 ATTR_SCENARIOS_APPLY, 

44 ATTR_SCENARIOS_CONSTRAIN, 

45 ATTR_SCENARIOS_REQUIRE, 

46 CONF_ACTION_GROUPS, 

47 CONF_ACTIONS, 

48 CONF_ARCHIVE, 

49 CONF_CAMERAS, 

50 CONF_DELIVERY, 

51 CONF_DUPE_CHECK, 

52 CONF_HOUSEKEEPING, 

53 CONF_MEDIA_PATH, 

54 CONF_MESSAGE, 

55 CONF_MOBILE_DISCOVERY, 

56 CONF_RECIPIENTS, 

57 CONF_RECIPIENTS_DISCOVERY, 

58 CONF_SCENARIO_CONTROL, 

59 CONF_SCENARIOS, 

60 CONF_SNOOZE, 

61 CONF_TEMPLATE_PATH, 

62 CONF_TITLE, 

63 CONF_TRANSPORTS, 

64 DELIVERY_SELECTION_EXPLICIT, 

65 OVERRIDE_KINDS, 

66) 

67from .engine import SupernotifyEngine 

68from .model import CommandType, QualifiedTargetType, RecipientType 

69from .schema import ACTION_DATA_FIELDS, NOTIFY_ACTION_SCHEMA 

70from .snoozer import SNOOZE_SCOPES, snooze_name_error 

71from .target import Target 

72 

73if TYPE_CHECKING: 

74 from homeassistant.helpers.typing import ConfigType 

75 from homeassistant.util.json import JsonObjectType 

76 

77_LOGGER = logging.getLogger(__name__) 

78 

79# what the schema fills in for fields a supernotify.notify call left out 

80_NOTIFY_ACTION_DEFAULTS: dict[str, Any] = NOTIFY_ACTION_SCHEMA({}) 

81 

82 

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

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

85 

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

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

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

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

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

91 keep the rest as pass-through. 

92 

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

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

95 """ 

96 data = dict(data) 

97 nested = data.get(ATTR_DATA) 

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

99 _LOGGER.warning( 

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

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

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

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

104 ) 

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

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

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

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

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

110 data = lifted | { 

111 k: v for k, v in data.items() if k != ATTR_DATA and (k not in lifted or (v and v != _NOTIFY_ACTION_DEFAULTS.get(k))) 

112 } 

113 if passthrough: 

114 data[ATTR_DATA] = passthrough 

115 return data 

116 

117 

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

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

120 written as it could have been in YAML. 

121 

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

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

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

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

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

127 """ 

128 data = dict(data) 

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

130 picked: Any = data.get(ATTR_DELIVERY) 

131 if not control: 

132 return data 

133 if not picked: 

134 data[ATTR_DELIVERY] = control 

135 return data 

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

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

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

139 data[ATTR_DELIVERY] = merged 

140 if not isinstance(picked, dict): 

141 data.setdefault(ATTR_DELIVERY_SELECTION, DELIVERY_SELECTION_EXPLICIT) 

142 else: 

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

144 return data 

145 

146 

147VERBOSITY_SUMMARY: Final[str] = "summary" 

148VERBOSITY_STANDARD: Final[str] = "standard" 

149VERBOSITY_FULL: Final[str] = "full" 

150# counts per local day instead of the notifications themselves, and so without a default limit 

151VERBOSITY_DAILY: Final[str] = "daily" 

152 

153# time periods ending now, as offered by the Home Assistant Activity view 

154ARCHIVE_PERIODS: Final[dict[str, dt.timedelta]] = { 

155 "last_hour": dt.timedelta(hours=1), 

156 "last_12_hours": dt.timedelta(hours=12), 

157 "last_day": dt.timedelta(days=1), 

158 "last_week": dt.timedelta(weeks=1), 

159 "last_month": dt.timedelta(days=30), 

160} 

161 

162# only the fields with a fixed choice of values are checked, the rest are as loose as they were 

163ENQUIRE_ARCHIVE_SCHEMA: Final[vol.Schema] = vol.Schema( 

164 { 

165 vol.Optional("period"): vol.In(ARCHIVE_PERIODS), 

166 vol.Optional("verbosity"): vol.In((VERBOSITY_SUMMARY, VERBOSITY_STANDARD, VERBOSITY_FULL, VERBOSITY_DAILY)), 

167 vol.Optional("priority"): vol.All(cv.ensure_list, [vol.Coerce(str)]), 

168 vol.Optional("after"): cv.datetime, 

169 vol.Optional("before"): cv.datetime, 

170 }, 

171 extra=vol.ALLOW_EXTRA, 

172) 

173 

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

175 "notify", 

176 "enquire_archive", 

177 "enquire_configuration", 

178 "enquire_implicit_deliveries", 

179 "enquire_deliveries_by_scenario", 

180 "enquire_last_notification", 

181 "enquire_active_scenarios", 

182 "enquire_scenarios", 

183 "enquire_occupancy", 

184 "enquire_recipients", 

185 "enquire_snoozes", 

186 "clear_snoozes", 

187 "snooze", 

188 "silence", 

189 "unsnooze", 

190 "purge_archive", 

191 "purge_media", 

192 "refresh_entities", 

193 "reset_overrides", 

194) 

195 

196SNOOZE_MAX_MINUTES: Final[int] = 7 * 24 * 60 

197SNOOZE_REASON: Final[str] = "Action" 

198# what to snooze, silence or unsnooze, the same for all three actions 

199SNOOZE_TARGET_FIELDS: Final[dict[vol.Marker, Any]] = { 

200 vol.Optional("scope", default="everything"): vol.In(SNOOZE_SCOPES), 

201 vol.Optional("name"): cv.string, 

202 vol.Optional("person"): cv.entity_domain("person"), 

203} 

204# supernotify.snooze, supernotify.silence and supernotify.unsnooze: the command of a mobile action, 

205# one Home Assistant action each 

206SNOOZE_ACTIONS: Final[dict[str, tuple[CommandType, vol.Schema]]] = { 

207 "snooze": ( 

208 CommandType.SNOOZE, 

209 vol.Schema({ 

210 **SNOOZE_TARGET_FIELDS, 

211 vol.Optional("minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=SNOOZE_MAX_MINUTES)), 

212 vol.Optional("reason"): cv.string, 

213 }), 

214 ), 

215 "silence": (CommandType.SILENCE, vol.Schema({**SNOOZE_TARGET_FIELDS, vol.Optional("reason"): cv.string})), 

216 "unsnooze": (CommandType.NORMAL, vol.Schema(SNOOZE_TARGET_FIELDS)), 

217} 

218 

219ATTR_KIND: Final[str] = "kind" 

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

221 

222 

223def _json_safe(v: Any) -> Any: # ruff: ignore[any-type] 

224 """Config as validated holds Template objects (e.g. a delivery's template conditions), which a 

225 service response can't serialize: give their source text instead, and lists for tuples/sets.""" 

226 if isinstance(v, Template): 

227 return v.template 

228 if isinstance(v, dict): 

229 return {k: _json_safe(x) for k, x in v.items()} 

230 if isinstance(v, list | tuple | set): 

231 return [_json_safe(x) for x in v] 

232 return v 

233 

234 

235@callback 

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

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

238 

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

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

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

242 twice - see ACTION_NAMES/async_unregister_engine_actions for the 

243 matching teardown. 

244 

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

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

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

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

249 reconstruct them. 

250 """ 

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

252 return 

253 

254 async def action_notify(call: ServiceCall) -> JsonObjectType | None: 

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

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

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

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

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

260 

261 `dry_run` is a standard action-data field like priority/force_resend (see ATTR_DRY_RUN) - 

262 Notification.call_transport() is what actually skips sending, so there's no special case 

263 for it here. Requesting a response (supports_response=OPTIONAL) returns the notification's 

264 contents either way. 

265 """ 

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

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

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

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

270 message = data.pop(CONF_MESSAGE, "") 

271 title = data.pop(CONF_TITLE, None) 

272 target = data.pop(CONF_TARGET, None) 

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

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

275 # know this field exists 

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

277 if custom_target: 

278 if isinstance(target, dict): 

279 merged_target = dict(target) 

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

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

282 target = merged_target 

283 else: 

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

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

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

287 promoted_media = { 

288 key: data.pop(key) 

289 for key in (ATTR_MEDIA_CAMERA_ENTITY_ID, ATTR_MEDIA_CLIP_URL, ATTR_MEDIA_SNAPSHOT_URL) 

290 if key in data 

291 } 

292 if promoted_media: 

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

294 media.update(promoted_media) 

295 data[ATTR_MEDIA] = media 

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

297 return notification.contents() if notification else None 

298 

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

300 return _json_safe({ 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

317 }) 

318 

319 @callback 

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

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

322 engine.refresh_entities() 

323 

324 @callback 

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

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

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

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

329 

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

331 return engine.enquire_implicit_deliveries() 

332 

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

334 return engine.enquire_deliveries_by_scenario() 

335 

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

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

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

339 

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

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

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

343 if trace: 

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

345 return result 

346 

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

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

349 

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

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

352 

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

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

355 

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

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

358 

359 @callback 

360 def snooze_command(cmd: CommandType, call: ServiceCall) -> dict[str, Any]: 

361 """supernotify.snooze, supernotify.silence and supernotify.unsnooze - the same snooze, silence or 

362 unsnooze as a mobile action, voice sentence or the AI tool, for scripts, automations and dashboards, 

363 and open to any user, where firing the mobile action event over the API needs an admin. Run in the 

364 event loop like the others that change state.""" 

365 scope = SNOOZE_SCOPES[call.data["scope"]] 

366 name: str | None = call.data.get("name") 

367 if isinstance(scope, QualifiedTargetType): 

368 if problem := snooze_name_error(engine, scope, name, cmd): 

369 raise ServiceValidationError( 

370 translation_domain=DOMAIN, translation_key=problem[0], translation_placeholders=problem[1] 

371 ) 

372 else: 

373 name = None 

374 person: str | None = call.data.get("person") 

375 if person and person not in engine.context.people_registry.people: 

376 raise ServiceValidationError( 

377 translation_domain=DOMAIN, 

378 translation_key="snooze_not_recipient", 

379 translation_placeholders={"person": person}, 

380 ) 

381 minutes: int | None = call.data.get("minutes") 

382 snoozer = engine.context.snoozer 

383 snoozer.register_snooze( 

384 cmd, 

385 scope, 

386 name, 

387 RecipientType.USER if person else RecipientType.EVERYONE, 

388 person, 

389 dt.timedelta(minutes=minutes) if minutes else snoozer.snooze_period, 

390 reason=call.data.get("reason") or SNOOZE_REASON, 

391 ) 

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

393 

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

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

396 

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

398 archive = engine.context.archive 

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

400 raise ServiceValidationError( 

401 translation_domain=DOMAIN, 

402 translation_key="no_archive_configured", 

403 ) 

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

405 verbosity: str = call.data.get("verbosity") or (VERBOSITY_FULL if notification_id else VERBOSITY_STANDARD) 

406 

407 def at_verbosity(contents: dict[str, Any]) -> dict[str, Any]: 

408 if verbosity == VERBOSITY_SUMMARY: 

409 # delivery_provenance is about half of a summary: it stays at standard and full 

410 return summarize_notification(engine, contents, include_provenance=False) 

411 if verbosity == VERBOSITY_STANDARD: 

412 return {k: v for k, v in contents.items() if k != "debug_trace"} 

413 return contents 

414 

415 if notification_id: 

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

417 if entry is None: 

418 raise ServiceValidationError( 

419 translation_domain=DOMAIN, 

420 translation_key="archive_entry_not_found", 

421 translation_placeholders={"notification_id": notification_id}, 

422 ) 

423 return summarize_by_day([entry]) if verbosity == VERBOSITY_DAILY else at_verbosity(entry) 

424 daily: bool = verbosity == VERBOSITY_DAILY 

425 limit: int = int(call.data["limit"]) if "limit" in call.data else (sys.maxsize if daily else 20) 

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

427 period: str | None = call.data.get("period") 

428 priority: list[str] | None = call.data.get("priority") or None 

429 # a date/time given without a time zone is local time 

430 after: dt.datetime | None = dt_util.as_local(call.data["after"]) if "after" in call.data else None 

431 before: dt.datetime | None = dt_util.as_local(call.data["before"]) if "before" in call.data else None 

432 if after is None and period: 

433 after = dt_util.now() - ARCHIVE_PERIODS[period] 

434 entries = await archive.archive_directory.list_entries( 

435 limit=limit, after=after, before=before, outcome=outcome, priority=priority 

436 ) 

437 if daily: 

438 return summarize_by_day(entries) 

439 return {"notifications": [at_verbosity(entry) for entry in entries], "count": len(entries)} 

440 

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

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

443 if not engine.context.archive.enabled: 

444 raise ServiceValidationError( 

445 translation_domain=DOMAIN, 

446 translation_key="no_archive_configured", 

447 ) 

448 purged = await engine.context.archive.cleanup(days=days) 

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

450 return { 

451 "purged": purged, 

452 "remaining": arch_size, 

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

454 } 

455 

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

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

458 if not engine.context.media_storage.media_path: 

459 raise ServiceValidationError( 

460 translation_domain=DOMAIN, 

461 translation_key="no_media_storage_configured", 

462 ) 

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

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

465 return { 

466 "purged": purged, 

467 "remaining": size, 

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

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

470 } 

471 

472 hass.services.async_register( 

473 DOMAIN, 

474 "notify", 

475 action_notify, 

476 schema=NOTIFY_ACTION_SCHEMA, 

477 supports_response=SupportsResponse.OPTIONAL, 

478 ) 

479 hass.services.async_register( 

480 DOMAIN, 

481 "enquire_configuration", 

482 supplemental_action_enquire_configuration, 

483 supports_response=SupportsResponse.ONLY, 

484 ) 

485 hass.services.async_register( 

486 DOMAIN, 

487 "enquire_implicit_deliveries", 

488 supplemental_action_enquire_implicit_deliveries, 

489 supports_response=SupportsResponse.ONLY, 

490 ) 

491 hass.services.async_register( 

492 DOMAIN, 

493 "enquire_deliveries_by_scenario", 

494 supplemental_action_enquire_deliveries_by_scenario, 

495 supports_response=SupportsResponse.ONLY, 

496 ) 

497 hass.services.async_register( 

498 DOMAIN, 

499 "enquire_archive", 

500 supplemental_action_enquire_archive, 

501 schema=ENQUIRE_ARCHIVE_SCHEMA, 

502 supports_response=SupportsResponse.ONLY, 

503 ) 

504 hass.services.async_register( 

505 DOMAIN, 

506 "enquire_last_notification", 

507 supplemental_action_enquire_last_notification, 

508 supports_response=SupportsResponse.ONLY, 

509 ) 

510 hass.services.async_register( 

511 DOMAIN, 

512 "enquire_active_scenarios", 

513 supplemental_action_enquire_active_scenarios, 

514 supports_response=SupportsResponse.ONLY, 

515 ) 

516 hass.services.async_register( 

517 DOMAIN, 

518 "enquire_scenarios", 

519 supplemental_action_enquire_scenarios, 

520 supports_response=SupportsResponse.ONLY, 

521 ) 

522 hass.services.async_register( 

523 DOMAIN, 

524 "enquire_occupancy", 

525 supplemental_action_enquire_occupancy, 

526 supports_response=SupportsResponse.ONLY, 

527 ) 

528 hass.services.async_register( 

529 DOMAIN, 

530 "enquire_recipients", 

531 supplemental_action_enquire_recipients, 

532 supports_response=SupportsResponse.ONLY, 

533 ) 

534 hass.services.async_register( 

535 DOMAIN, 

536 "enquire_snoozes", 

537 supplemental_action_enquire_snoozes, 

538 supports_response=SupportsResponse.ONLY, 

539 ) 

540 hass.services.async_register( 

541 DOMAIN, 

542 "clear_snoozes", 

543 supplemental_action_clear_snoozes, 

544 supports_response=SupportsResponse.ONLY, 

545 ) 

546 for action_name, (cmd, schema) in SNOOZE_ACTIONS.items(): 

547 hass.services.async_register( 

548 DOMAIN, 

549 action_name, 

550 callback(partial(snooze_command, cmd)), 

551 schema=schema, 

552 supports_response=SupportsResponse.OPTIONAL, 

553 ) 

554 hass.services.async_register( 

555 DOMAIN, 

556 "purge_archive", 

557 supplemental_action_purge_archive, 

558 supports_response=SupportsResponse.ONLY, 

559 ) 

560 hass.services.async_register( 

561 DOMAIN, 

562 "purge_media", 

563 supplemental_action_purge_media, 

564 supports_response=SupportsResponse.ONLY, 

565 ) 

566 hass.services.async_register( 

567 DOMAIN, 

568 "refresh_entities", 

569 supplemental_action_refresh_entities, 

570 supports_response=SupportsResponse.NONE, 

571 ) 

572 hass.services.async_register( 

573 DOMAIN, 

574 "reset_overrides", 

575 supplemental_action_reset_overrides, 

576 schema=RESET_OVERRIDES_SCHEMA, 

577 supports_response=SupportsResponse.OPTIONAL, 

578 ) 

579 

580 

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

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

583 

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

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

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

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

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

589 merge_delivery_fields(). 

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

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

592 """ 

593 integration = await async_get_integration(hass, DOMAIN) 

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

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

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

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

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

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

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

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

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

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

604 } 

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

606 

607 

608@callback 

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

610 """Undo async_register_engine_actions.""" 

611 for name in ACTION_NAMES: 

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

613 hass.services.async_remove(DOMAIN, name)