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
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
1"""Supernotify service, extending BaseNotificationService"""
3from __future__ import annotations
5import datetime as dt
6import logging
7import sys
8from functools import partial
9from typing import TYPE_CHECKING, Any, Final
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
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
73if TYPE_CHECKING:
74 from homeassistant.helpers.typing import ConfigType
75 from homeassistant.util.json import JsonObjectType
77_LOGGER = logging.getLogger(__name__)
79# what the schema fills in for fields a supernotify.notify call left out
80_NOTIFY_ACTION_DEFAULTS: dict[str, Any] = NOTIFY_ACTION_SCHEMA({})
83def lift_legacy_nested_data(data: dict[str, Any]) -> dict[str, Any]:
84 """Migrate a notify.supernotify-shaped payload sent to supernotify.notify.
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.
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
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.
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
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"
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}
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)
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)
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}
219ATTR_KIND: Final[str] = "kind"
220RESET_OVERRIDES_SCHEMA: Final = vol.Schema({vol.Optional(ATTR_KIND): vol.In(OVERRIDE_KINDS)})
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
235@callback
236def async_register_engine_actions(hass: HomeAssistant, engine: SupernotifyEngine, config: ConfigType) -> None:
237 """Register the domain-scoped supplemental/debugging/admin services.
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.
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
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>.
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
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 })
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()
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)}
330 def supplemental_action_enquire_implicit_deliveries(_call: ServiceCall) -> dict[str, Any]:
331 return engine.enquire_implicit_deliveries()
333 def supplemental_action_enquire_deliveries_by_scenario(_call: ServiceCall) -> dict[str, Any]:
334 return engine.enquire_deliveries_by_scenario()
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 {}
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
347 def supplemental_action_enquire_scenarios(_call: ServiceCall) -> dict[str, Any]:
348 return {"scenarios": engine.enquire_scenarios()}
350 async def supplemental_action_enquire_occupancy(_call: ServiceCall) -> dict[str, Any]:
351 return {"scenarios": await engine.enquire_occupancy()}
353 def supplemental_action_enquire_snoozes(_call: ServiceCall) -> dict[str, Any]:
354 return {"snoozes": engine.enquire_snoozes()}
356 def supplemental_action_clear_snoozes(_call: ServiceCall) -> dict[str, Any]:
357 return {"cleared": engine.clear_snoozes()}
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()}
394 def supplemental_action_enquire_recipients(_call: ServiceCall) -> dict[str, Any]:
395 return {"recipients": engine.enquire_recipients()}
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)
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
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)}
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 }
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 }
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 )
581async def async_describe_configured_names(hass: HomeAssistant, engine: SupernotifyEngine) -> None:
582 """Show the deliveries and scenarios configured now in supernotify.notify's description.
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)
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)