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
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +0000
1"""Supernotify service, extending BaseNotificationService"""
3from __future__ import annotations
5import datetime as dt
6import logging
7from typing import TYPE_CHECKING, Any, Final
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
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
67if TYPE_CHECKING:
68 from homeassistant.helpers.typing import ConfigType
70_LOGGER = logging.getLogger(__name__)
73def lift_legacy_nested_data(data: dict[str, Any]) -> dict[str, Any]:
74 """Migrate a notify.supernotify-shaped payload sent to supernotify.notify.
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.
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
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.
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
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)
154ATTR_KIND: Final[str] = "kind"
155RESET_OVERRIDES_SCHEMA: Final = vol.Schema({vol.Optional(ATTR_KIND): vol.In(OVERRIDE_KINDS)})
158@callback
159def async_register_engine_actions(hass: HomeAssistant, engine: SupernotifyEngine, config: ConfigType) -> None:
160 """Register the domain-scoped supplemental/debugging/admin services.
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.
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
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)
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 }
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()
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)}
248 def supplemental_action_enquire_implicit_deliveries(_call: ServiceCall) -> dict[str, Any]:
249 return engine.enquire_implicit_deliveries()
251 def supplemental_action_enquire_deliveries_by_scenario(_call: ServiceCall) -> dict[str, Any]:
252 return engine.enquire_deliveries_by_scenario()
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 {}
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
265 def supplemental_action_enquire_scenarios(_call: ServiceCall) -> dict[str, Any]:
266 return {"scenarios": engine.enquire_scenarios()}
268 async def supplemental_action_enquire_occupancy(_call: ServiceCall) -> dict[str, Any]:
269 return {"scenarios": await engine.enquire_occupancy()}
271 def supplemental_action_enquire_snoozes(_call: ServiceCall) -> dict[str, Any]:
272 return {"snoozes": engine.enquire_snoozes()}
274 def supplemental_action_clear_snoozes(_call: ServiceCall) -> dict[str, Any]:
275 return {"cleared": engine.clear_snoozes()}
277 def supplemental_action_enquire_recipients(_call: ServiceCall) -> dict[str, Any]:
278 return {"recipients": engine.enquire_recipients()}
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)}
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 }
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 }
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 )
437async def async_describe_configured_names(hass: HomeAssistant, engine: SupernotifyEngine) -> None:
438 """Show the deliveries and scenarios configured now in supernotify.notify's description.
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)
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)