Coverage for custom_components/supernotify/schema.py: 96%
128 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"""The Supernotify integration"""
3import datetime as dt
4import re
5from collections.abc import Callable
6from enum import IntFlag, StrEnum, auto
7from typing import Any
9from homeassistant.const import (
10 CONF_ACTION,
11 CONF_ALIAS,
12 CONF_CONDITION,
13 CONF_CONDITIONS,
14 CONF_DEBUG,
15 CONF_DESCRIPTION,
16 CONF_EMAIL,
17 CONF_ENABLED,
18 CONF_EXCLUDE,
19 CONF_HOST,
20 CONF_ICON,
21 CONF_ID,
22 CONF_NAME,
23 CONF_PASSWORD,
24 CONF_PORT,
25 CONF_TARGET,
26 CONF_TIMEOUT,
27 CONF_URL,
28 CONF_USERNAME,
29 CONF_VERIFY_SSL,
30)
31from homeassistant.helpers import config_validation as cv
32from homeassistant.helpers.template import Template
33from homeassistant.helpers.typing import TemplateVarsType
35from custom_components.supernotify import ARCHIVE_DIR, MEDIA_DIR, TEMPLATE_DIR
37from .compat import vol
38from .const import (
39 ACTION_LEVEL_ACTION,
40 ACTION_LEVEL_NOTIFICATION,
41 ATTR_ACTION,
42 ATTR_ACTION_GROUPS,
43 ATTR_ACTION_URL,
44 ATTR_ACTION_URL_TITLE,
45 ATTR_ACTIONS,
46 ATTR_CUSTOM_TARGET,
47 ATTR_DATA,
48 ATTR_DEBUG,
49 ATTR_DELIVERY,
50 ATTR_DELIVERY_CONTROL,
51 ATTR_DELIVERY_SELECTION,
52 ATTR_DRY_RUN,
53 ATTR_DUPE_POLICY_MT,
54 ATTR_DUPE_POLICY_MTSLP,
55 ATTR_DUPE_POLICY_NONE,
56 ATTR_EMAIL,
57 ATTR_EXTRA_DATA,
58 ATTR_FORCE_RESEND,
59 ATTR_JPEG_OPTS,
60 ATTR_MEDIA,
61 ATTR_MEDIA_CAMERA_DELAY,
62 ATTR_MEDIA_CAMERA_ENTITY_ID,
63 ATTR_MEDIA_CAMERA_PTZ_PRESET,
64 ATTR_MEDIA_CLIP_URL,
65 ATTR_MEDIA_SNAPSHOT_PATH,
66 ATTR_MEDIA_SNAPSHOT_URL,
67 ATTR_MESSAGE_HTML,
68 ATTR_MOBILE_APP_ID,
69 ATTR_PERSON_ID,
70 ATTR_PHONE,
71 ATTR_PNG_OPTS,
72 ATTR_PRIORITY,
73 ATTR_RECIPIENTS,
74 ATTR_SCENARIOS_APPLY,
75 ATTR_SCENARIOS_CONSTRAIN,
76 ATTR_SCENARIOS_REQUIRE,
77 ATTR_SPOKEN_MESSAGE,
78 ATTR_TIMESTAMP,
79 ATTR_TITLE,
80 CONF_ACTION_GROUP_NAMES,
81 CONF_ACTION_GROUPS,
82 CONF_ACTION_TEMPLATE,
83 CONF_ALT_CAMERA,
84 CONF_ARCHIVE,
85 CONF_ARCHIVE_DAYS,
86 CONF_ARCHIVE_DIAGNOSTICS,
87 CONF_ARCHIVE_EVENT_NAME,
88 CONF_ARCHIVE_EVENT_SELECTION,
89 CONF_ARCHIVE_MQTT_QOS,
90 CONF_ARCHIVE_MQTT_RETAIN,
91 CONF_ARCHIVE_MQTT_TOPIC,
92 CONF_ARCHIVE_PATH,
93 CONF_ARCHIVE_PURGE_INTERVAL,
94 CONF_CAMERA,
95 CONF_CAMERAS,
96 CONF_CLASS,
97 CONF_CONNECTION,
98 CONF_DATA,
99 CONF_DEFAULT_INCLUSION,
100 CONF_DELIVERY,
101 CONF_DELIVERY_CONTROL,
102 CONF_DELIVERY_DEFAULTS,
103 CONF_DEPRECATED_ENTITIES,
104 CONF_DEVICE_DISCOVERY,
105 CONF_DEVICE_DOMAIN,
106 CONF_DEVICE_MODEL_EXCLUDE,
107 CONF_DEVICE_MODEL_INCLUDE,
108 CONF_DEVICE_TRACKER,
109 CONF_DUPE_CHECK,
110 CONF_DUPE_POLICY,
111 CONF_ENCRYPTION,
112 CONF_EXPOSE_STATE,
113 CONF_FALLBACK,
114 CONF_HOUSEKEEPING,
115 CONF_HOUSEKEEPING_TIME,
116 CONF_INCLUSION,
117 CONF_LEVEL,
118 CONF_LOAD,
119 CONF_MANUFACTURER,
120 CONF_MEDIA,
121 CONF_MEDIA_PATH,
122 CONF_MEDIA_STORAGE_DAYS,
123 CONF_MEDIA_URL_PREFIX,
124 CONF_MESSAGE,
125 CONF_MOBILE_APP_ID,
126 CONF_MOBILE_DEVICES,
127 CONF_MOBILE_DISCOVERY,
128 CONF_MODEL,
129 CONF_NOTIFY_ENTITY,
130 CONF_OCCUPANCY,
131 CONF_OPTIONS,
132 CONF_PERSON,
133 CONF_PHONE_NUMBER,
134 CONF_PRIORITY,
135 CONF_PTZ_CAMERA,
136 CONF_PTZ_DELAY,
137 CONF_PTZ_METHOD,
138 CONF_PTZ_PRESET_DEFAULT,
139 CONF_RECIPIENTS,
140 CONF_RECIPIENTS_DISCOVERY,
141 CONF_REFRESH,
142 CONF_REFRESH_INTERVAL,
143 CONF_SCENARIO_CONTROL,
144 CONF_SCENARIOS,
145 CONF_SELECTION,
146 CONF_SELECTION_RANK,
147 CONF_SIZE,
148 CONF_SNAP_WAIT,
149 CONF_SNOOZE,
150 CONF_SNOOZE_TIME,
151 CONF_TARGET_REQUIRED,
152 CONF_TARGET_USAGE,
153 CONF_TEMPLATE,
154 CONF_TEMPLATE_PATH,
155 CONF_TITLE,
156 CONF_TITLE_TEMPLATE,
157 CONF_TRANSPORT,
158 CONF_TRANSPORTS,
159 CONF_TTL,
160 CONF_URI,
161 CONF_URI_TEMPLATE,
162 CONF_USER_ID,
163 CONF_VOICE_OCCUPANCY,
164 DEFAULT_INCLUSION_VALUES,
165 DELIVERY_SELECTION_VALUES,
166 DRY_RUN_LIVE,
167 DRY_RUN_VALUES,
168 INCLUSION_VALUES,
169 OCCUPANCY_VALUES,
170 PRIORITY_VALUES,
171 PTZ_DELAY_DEFAULT,
172 PTZ_METHOD_ONVIF,
173 PTZ_METHOD_VALUES,
174 RESERVED_DATA_KEYS,
175 RESERVED_SCENARIO_NAMES,
176 SCENARIO_STATE_REFRESH_DEFAULT,
177 SNAP_WAIT_DEFAULT,
178 TARGET_REQUIRE_ALWAYS,
179 TARGET_REQUIRE_NEVER,
180 TARGET_REQUIRE_OPTIONAL,
181 TARGET_USE_FIXED,
182 TARGET_USE_MERGE_ALWAYS,
183 TARGET_USE_MERGE_ON_DELIVERY_TARGETS,
184 TARGET_USE_ON_NO_ACTION_TARGETS,
185 TARGET_USE_ON_NO_DELIVERY_TARGETS,
186 TRANSPORT_VALUES,
187)
190class OutcomeSelection(IntFlag):
191 NONE = 0
192 SUCCESS = 1
193 NO_DELIVERY = 2
194 PARTIAL_DELIVERY = 4
195 FALLBACK_DELIVERY = 8
196 ERROR = 16
197 DUPE = 32
198 ALL = 65536
201def parse_event_policy(value: object) -> OutcomeSelection:
202 if isinstance(value, OutcomeSelection):
203 return value
204 if isinstance(value, int):
205 return OutcomeSelection(value)
206 if isinstance(value, str):
207 result = OutcomeSelection.NONE
208 for part in value.split("|"):
209 result |= OutcomeSelection[part.strip()]
210 return result
211 raise vol.Invalid(f"expected OutcomeSelection, got {value!r}")
214class DeliveryOutcome(StrEnum):
215 SUCCESS = auto()
216 NO_DELIVERY = auto()
217 PARTIAL_DELIVERY = auto()
218 FALLBACK_DELIVERY = auto()
219 ERROR = auto()
220 DUPE = auto()
223class EnvelopeOutcome(StrEnum):
224 SUCCESS = auto()
225 ERROR = auto()
226 SKIPPED = auto() # envelope not generated
227 SUPPRESSED = auto() # envelope generated but intentionally not delivered
230class SelectionRank(StrEnum):
231 FIRST = "FIRST"
232 ANY = "ANY"
233 LAST = "LAST"
236type ConditionsFunc = Callable[[TemplateVarsType], bool]
239def phone(value: str) -> str:
240 """Validate a phone number"""
241 regex = re.compile(r"^(\+\d{1,3})?\s?\(?\d{1,4}\)?[\s.-]?\d{3}[\s.-]?\d{4}$")
242 if not regex.match(value):
243 raise vol.Invalid("Invalid Phone Number")
244 return str(value)
247def validate_scenario_names(scenarios: dict) -> dict:
248 """Validate that scenario names are not reserved."""
249 for name in scenarios:
250 if name in RESERVED_SCENARIO_NAMES:
251 raise vol.Invalid(f"'{name}' is a reserved scenario name")
252 return scenarios
255# entity, device, area, floor, label ids - without the `exclude` block TARGET_FIELDS has from
256# Home Assistant 2026.11, which is only taken by the supernotify.notify action
257_TARGET_ID_FIELDS = {k: v for k, v in cv.TARGET_FIELDS.items() if str(k) != CONF_EXCLUDE}
258_TARGET_FIELDS = _TARGET_ID_FIELDS | {
259 vol.Optional(ATTR_EMAIL): vol.All(cv.ensure_list, [vol.Email()]),
260 vol.Optional(ATTR_PHONE): vol.All(cv.ensure_list, [phone]),
261 vol.Optional(ATTR_MOBILE_APP_ID): vol.All(cv.ensure_list, [cv.service]),
262 vol.Optional(ATTR_PERSON_ID): vol.All(cv.ensure_list, [cv.entity_id]),
263 vol.Optional(cv.string): vol.All(cv.ensure_list, [str]),
264}
265TARGET_SCHEMA = vol.Any( # order of schema matters, voluptuous forces into first it finds that works
266 _TARGET_FIELDS,
267 str,
268 # any list, as unquoted phone numbers arrive as numbers - but not list[str], which voluptuous
269 # calls, turning a mapping that failed above into a list of its keys
270 list,
271)
272# a target with what to leave out of it, as the target selector's 'Exclude target' gives from
273# Home Assistant 2026.11 - defined here rather than taken from it, for earlier versions
274ACTION_TARGET_SCHEMA = vol.Any(
275 _TARGET_FIELDS | {vol.Optional(CONF_EXCLUDE): vol.Schema(_TARGET_ID_FIELDS)},
276 str,
277 list,
278)
280DATA_SCHEMA = vol.Schema({vol.NotIn(RESERVED_DATA_KEYS): vol.Any(None, str, int, bool, float, dict, list)})
282MOBILE_DEVICE_SCHEMA = vol.Schema({
283 vol.Optional(CONF_MANUFACTURER): cv.string,
284 vol.Optional(CONF_MODEL): cv.string,
285 vol.Optional(CONF_CLASS): cv.string,
286 vol.Optional(CONF_MOBILE_APP_ID): cv.string,
287 vol.Optional(CONF_DEVICE_TRACKER): cv.entity_id,
288 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
289})
290NOTIFICATION_DUPE_SCHEMA = vol.Schema({
291 vol.Optional(CONF_TTL, default=120): cv.positive_int,
292 vol.Optional(CONF_SIZE, default=100): cv.positive_int,
293 vol.Optional(CONF_DUPE_POLICY, default=ATTR_DUPE_POLICY_MTSLP): vol.In([
294 ATTR_DUPE_POLICY_MTSLP,
295 ATTR_DUPE_POLICY_MT,
296 ATTR_DUPE_POLICY_NONE,
297 ]),
298})
301DELIVERY_CUSTOMIZE_SCHEMA = vol.All(
302 vol.Schema(
303 {
304 vol.Optional(CONF_TARGET): TARGET_SCHEMA,
305 vol.Optional(CONF_ENABLED): vol.Any(None, cv.boolean),
306 vol.Optional(CONF_DATA): DATA_SCHEMA,
307 },
308 ),
309)
310LINK_SCHEMA = vol.Schema({
311 vol.Required(CONF_URL): cv.url,
312 vol.Required(CONF_DESCRIPTION): cv.string,
313 vol.Optional(CONF_ID): cv.string,
314 vol.Optional(CONF_ICON): cv.icon,
315 vol.Optional(CONF_NAME): cv.string,
316})
318# global scenario control options and defaults, advanced config, YAML only
319SCENARIO_CONTROL_SCHEMA = vol.Schema({
320 # Evaluating scenario conditions to publish binary_sensor state costs whatever the
321 # conditions cost, which for template-heavy scenarios is not free on small hardware.
322 vol.Optional(CONF_REFRESH, default=False): cv.boolean,
323 # Sweep every scenario this often, for conditions no entity change can announce (time
324 # windows, sun, templates whose dependencies could not be extracted). 0 disables the
325 # sweep and leaves state entirely event driven.
326 vol.Optional(CONF_REFRESH_INTERVAL, default=SCENARIO_STATE_REFRESH_DEFAULT): cv.positive_int,
327})
329SNOOZE_SCHEMA = vol.Schema({vol.Optional(CONF_SNOOZE_TIME, default=60 * 60): cv.positive_int})
331DELIVERY_CONFIG_SCHEMA = vol.Schema({ # shared by Transport Defaults and Delivery definitions
332 # defaults set in model.DeliveryConfig
333 vol.Optional(CONF_ACTION): cv.service, # previously 'service:'
334 vol.Optional(CONF_DEBUG): cv.boolean,
335 vol.Optional(CONF_OPTIONS): dict, # transport tuning
336 vol.Optional(CONF_DATA): DATA_SCHEMA,
337 vol.Optional(CONF_TARGET): TARGET_SCHEMA,
338 vol.Optional(CONF_TARGET_REQUIRED): vol.Any(
339 cv.boolean, vol.In([TARGET_REQUIRE_ALWAYS, TARGET_REQUIRE_NEVER, TARGET_REQUIRE_OPTIONAL])
340 ),
341 vol.Optional(CONF_TARGET_USAGE): vol.In([
342 TARGET_USE_ON_NO_DELIVERY_TARGETS,
343 TARGET_USE_ON_NO_ACTION_TARGETS,
344 TARGET_USE_MERGE_ON_DELIVERY_TARGETS,
345 TARGET_USE_MERGE_ALWAYS,
346 TARGET_USE_FIXED,
347 ]),
348 vol.Optional(CONF_INCLUSION): vol.All(cv.ensure_list, [vol.In(INCLUSION_VALUES)]),
349 vol.Optional(CONF_PRIORITY): vol.All(cv.ensure_list, [vol.Any(int, str, vol.In(list(PRIORITY_VALUES.keys())))]),
350 vol.Optional(CONF_SELECTION_RANK): vol.In([
351 SelectionRank.ANY,
352 SelectionRank.FIRST,
353 SelectionRank.LAST,
354 ]),
355 # also settable at Delivery level - a Transport's delivery_defaults let these be
356 # configured once for its auto-generated delivery, without needing an explicit Delivery
357 vol.Optional(CONF_ALIAS): cv.string,
358 vol.Optional(CONF_TEMPLATE): cv.string,
359 vol.Optional(CONF_MESSAGE): vol.Any(None, cv.string),
360 vol.Optional(CONF_TITLE): vol.Any(None, cv.string),
361 # no default here, which would hide a transport's own - DeliveryConfig falls back to OCCUPANCY_ALL
362 vol.Optional(CONF_OCCUPANCY): vol.In(OCCUPANCY_VALUES),
363 vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA,
364})
367def _migrate_condition(config: dict) -> dict:
368 """Migrate deprecated 'condition' key to 'conditions'."""
369 if CONF_CONDITION in config:
370 if CONF_CONDITIONS not in config:
371 config = {**config, CONF_CONDITIONS: config[CONF_CONDITION]}
372 config = {k: v for k, v in config.items() if k != CONF_CONDITION}
373 return config
376DELIVERY_SCHEMA = vol.All(
377 _migrate_condition,
378 cv.deprecated(key=CONF_SELECTION, replacement_key=CONF_INCLUSION),
379 DELIVERY_CONFIG_SCHEMA.extend({
380 vol.Required(CONF_TRANSPORT): vol.In(TRANSPORT_VALUES),
381 vol.Optional(CONF_ENABLED): cv.boolean,
382 # deliveries to try in order when this one fails, see Notification._fall_back()
383 vol.Optional(CONF_FALLBACK): vol.All(cv.ensure_list, [cv.string]),
384 vol.Optional(CONF_NOTIFY_ENTITY): cv.slug,
385 }),
386)
388CONNECTION_SCHEMA = vol.Schema({
389 vol.Required(CONF_HOST): cv.string,
390 vol.Optional(CONF_PORT, default=587): cv.port,
391 vol.Optional(CONF_ENCRYPTION, default="starttls"): vol.In(["tls", "starttls", "none"]),
392 vol.Optional(CONF_USERNAME): cv.string,
393 vol.Optional(CONF_PASSWORD): cv.string,
394 vol.Optional(CONF_TIMEOUT, default=5): cv.positive_int,
395 vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
396})
398TRANSPORT_SCHEMA = vol.All(
399 cv.deprecated(key=CONF_DEVICE_DOMAIN), # deprecated v1.9.0
400 cv.deprecated(key=CONF_DEVICE_DISCOVERY), # deprecated v1.9.0
401 cv.deprecated(key=CONF_DEVICE_MODEL_INCLUDE), # deprecated v1.9.0
402 cv.deprecated(key=CONF_DEVICE_MODEL_EXCLUDE), # deprecated v1.9.0
403 vol.Schema({
404 vol.Optional(CONF_ALIAS): cv.string,
405 vol.Optional(CONF_LOAD, default=True): cv.boolean,
406 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
407 vol.Optional(CONF_DELIVERY_DEFAULTS): vol.All(
408 cv.deprecated(key=CONF_SELECTION, replacement_key=CONF_INCLUSION), DELIVERY_CONFIG_SCHEMA
409 ),
410 # only meaningful for transports (eg smtp) that own their own network connection
411 vol.Optional(CONF_CONNECTION): CONNECTION_SCHEMA,
412 # deprecated, replaced by options usage
413 vol.Optional(CONF_DEVICE_DOMAIN): vol.All(cv.ensure_list, [cv.string]),
414 vol.Optional(CONF_DEVICE_MODEL_INCLUDE): vol.All(cv.ensure_list, [cv.string]),
415 vol.Optional(CONF_DEVICE_MODEL_EXCLUDE): vol.All(cv.ensure_list, [cv.string]),
416 vol.Optional(CONF_DEVICE_DISCOVERY): cv.boolean,
417 }),
418)
419# Idea - differentiate enabled as recipient vs as occupant, for ALL_IN etc check
420# May need condition, and also enabled if delivery disabled
421# CONF_OCCUPANCY="occupancy"
422# OPTION_OCCUPANCY_DEFAULT="default"
423# OPTIONS_OCCUPANCY=[OPTION_OCCUPANCY_DEFAULT,OPTION_OCCUPANCY_EXCLUDE]
424# OPTION_OCCUPANCY_EXCLUDE="exclude"
427# how each delivery, by name, treats one recipient
428RECIPIENT_DELIVERY_SCHEMA = vol.Schema({cv.string: DELIVERY_CUSTOMIZE_SCHEMA})
429RECIPIENT_SCHEMA = vol.All(
430 vol.Schema({
431 vol.Optional(CONF_PERSON): cv.entity_id,
432 vol.Optional(CONF_USER_ID): cv.string,
433 vol.Optional(CONF_ALIAS): cv.string,
434 vol.Optional(CONF_EMAIL): cv.string,
435 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
436 # vol.Optional(CONF_OCCUPANCY,default=OPTION_OCCUPANCY_DEFAULT):vol.In(OPTIONS_OCCUPANCY),
437 vol.Optional(CONF_TARGET): TARGET_SCHEMA,
438 vol.Optional(CONF_PHONE_NUMBER): cv.string,
439 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean,
440 vol.Optional(CONF_MOBILE_DEVICES, default=list): vol.All(cv.ensure_list, [MOBILE_DEVICE_SCHEMA]),
441 vol.Optional(CONF_DELIVERY, default=dict): RECIPIENT_DELIVERY_SCHEMA,
442 }),
443 # an alias alone is someone who isn't a Home Assistant user, reached by e-mail, phone or targets
444 cv.has_at_least_one_key(CONF_PERSON, CONF_USER_ID, CONF_ALIAS),
445)
446CAMERA_SCHEMA = vol.Schema({
447 vol.Required(CONF_CAMERA): cv.entity_id,
448 vol.Optional(CONF_ALT_CAMERA): vol.All(cv.ensure_list, [cv.entity_id]),
449 vol.Optional(CONF_ALIAS): cv.string,
450 vol.Optional(CONF_URL): cv.url,
451 vol.Optional(CONF_DEVICE_TRACKER): cv.entity_id,
452 vol.Optional(CONF_PTZ_CAMERA): cv.entity_id,
453 vol.Optional(CONF_PTZ_PRESET_DEFAULT, default=1): vol.Any(cv.positive_int, cv.string),
454 vol.Optional(CONF_PTZ_DELAY, default=PTZ_DELAY_DEFAULT): int,
455 vol.Optional(CONF_PTZ_METHOD, default=PTZ_METHOD_ONVIF): vol.In(PTZ_METHOD_VALUES),
456 vol.Optional(CONF_SNAP_WAIT, default=SNAP_WAIT_DEFAULT): int,
457})
458MEDIA_SCHEMA = vol.Schema({
459 vol.Optional(ATTR_MEDIA_CAMERA_ENTITY_ID): cv.entity_id,
460 vol.Optional(ATTR_MEDIA_CAMERA_DELAY): int,
461 vol.Optional(ATTR_MEDIA_CAMERA_PTZ_PRESET): vol.Any(cv.positive_int, cv.string),
462 # URL fragments allowed
463 vol.Optional(ATTR_MEDIA_CLIP_URL): vol.Any(cv.url, cv.string),
464 vol.Optional(ATTR_MEDIA_SNAPSHOT_URL): vol.Any(cv.url, cv.string),
465 vol.Optional(ATTR_MEDIA_SNAPSHOT_PATH): cv.path,
466 vol.Optional(ATTR_JPEG_OPTS): dict,
467 vol.Optional(ATTR_PNG_OPTS): dict,
468})
471SCENARIO_SCHEMA = vol.All(
472 _migrate_condition,
473 cv.deprecated(key="delivery_selection"),
474 vol.Schema({
475 vol.Optional(CONF_ALIAS): cv.string,
476 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
477 vol.Optional(CONF_EXPOSE_STATE, default=True): cv.boolean,
478 vol.Optional(CONF_NOTIFY_ENTITY): cv.slug,
479 vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA,
480 vol.Optional(CONF_MEDIA): MEDIA_SCHEMA,
481 vol.Optional(CONF_ACTION_GROUP_NAMES, default=[]): vol.All(cv.ensure_list, [cv.string]),
482 vol.Optional("delivery_selection"): cv.string,
483 # a single delivery or a list of them switches those on, a mapping can also tune or restrict
484 vol.Optional(CONF_DELIVERY, default=dict): vol.Any(
485 cv.string, [cv.string], {cv.string: vol.Any(None, DELIVERY_CUSTOMIZE_SCHEMA)}
486 ),
487 }),
488)
491def _mobile_action_uri(value: str) -> str:
492 """Validate URI for mobile actions — accepts http/https and homeassistant:// deep links."""
493 if value and not (value.startswith(("http://", "https://", "homeassistant://"))):
494 raise vol.Invalid(f"Invalid URI {value!r}: must start with http://, https://, or homeassistant://")
495 return value
498def _mobile_action_level(action: dict[str, Any]) -> dict[str, Any]:
499 """A notification level action is the URI opened by tapping the notification, not a button"""
500 if action.get(CONF_LEVEL) == ACTION_LEVEL_NOTIFICATION:
501 if not (action.get(CONF_URI) or action.get(CONF_URI_TEMPLATE)):
502 raise vol.Invalid(
503 f"A {CONF_LEVEL}: {ACTION_LEVEL_NOTIFICATION} action must have a {CONF_URI} or {CONF_URI_TEMPLATE}"
504 )
505 if CONF_ACTION in action or CONF_ACTION_TEMPLATE in action:
506 raise vol.Invalid(
507 f"A {CONF_LEVEL}: {ACTION_LEVEL_NOTIFICATION} action cannot have an {CONF_ACTION} or {CONF_ACTION_TEMPLATE}"
508 )
509 return action
512def _single_notification_level(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
513 if sum(1 for action in actions if action.get(CONF_LEVEL) == ACTION_LEVEL_NOTIFICATION) > 1:
514 raise vol.Invalid(f"Only one action can be {CONF_LEVEL}: {ACTION_LEVEL_NOTIFICATION}")
515 return actions
518_MOBILE_ACTION_LEVELS = vol.In([ACTION_LEVEL_ACTION, ACTION_LEVEL_NOTIFICATION])
520MOBILE_ACTION_CALL_SCHEMA = vol.All(
521 cv.deprecated(key="action_category"), # deprecated v2.5.0
522 vol.Schema(
523 {
524 vol.Optional(ATTR_ACTION): cv.string,
525 vol.Optional(ATTR_TITLE): cv.string,
526 vol.Optional("action_category"): cv.string,
527 vol.Optional(ATTR_ACTION_URL): cv.url,
528 vol.Optional(ATTR_ACTION_URL_TITLE): cv.string,
529 vol.Optional(CONF_LEVEL): _MOBILE_ACTION_LEVELS,
530 },
531 extra=vol.ALLOW_EXTRA,
532 ),
533 _mobile_action_level,
534)
536MOBILE_ACTION_SCHEMA = vol.All(
537 vol.Schema(
538 {
539 vol.Exclusive(CONF_ACTION, CONF_ACTION_TEMPLATE): cv.string,
540 vol.Exclusive(CONF_TITLE, CONF_TITLE_TEMPLATE): cv.string,
541 vol.Exclusive(CONF_URI, CONF_URI_TEMPLATE): _mobile_action_uri,
542 vol.Exclusive(CONF_URI_TEMPLATE, CONF_URI_TEMPLATE): cv.string,
543 vol.Optional(CONF_ICON): cv.string,
544 vol.Optional(CONF_LEVEL): _MOBILE_ACTION_LEVELS,
545 },
546 extra=vol.ALLOW_EXTRA,
547 ),
548 _mobile_action_level,
549)
552ARCHIVE_SCHEMA = vol.All(
553 cv.deprecated(key=CONF_DEBUG), # deprecated v1.10.0
554 vol.Schema({
555 vol.Optional(CONF_ARCHIVE_PATH, default=ARCHIVE_DIR): cv.path,
556 vol.Optional(CONF_ENABLED, default=False): cv.boolean,
557 vol.Optional(CONF_ARCHIVE_DAYS, default=3): cv.positive_int,
558 vol.Optional(CONF_ARCHIVE_MQTT_TOPIC): cv.string,
559 vol.Optional(CONF_ARCHIVE_MQTT_QOS, default=0): cv.positive_int,
560 vol.Optional(CONF_ARCHIVE_MQTT_RETAIN, default=False): cv.boolean,
561 vol.Optional(CONF_ARCHIVE_PURGE_INTERVAL): cv.positive_int, # deprecated v2.16.2, ignored
562 vol.Optional(CONF_ARCHIVE_EVENT_NAME, default="supernotification"): cv.string,
563 vol.Optional(CONF_ARCHIVE_EVENT_SELECTION, default=OutcomeSelection.NONE): parse_event_policy,
564 vol.Optional(CONF_ARCHIVE_DIAGNOSTICS, default=OutcomeSelection.ERROR): parse_event_policy,
565 vol.Optional(CONF_DEBUG, default=False): cv.boolean, # deprecated
566 }),
567)
569HOUSEKEEPING_SCHEMA = vol.Schema({
570 vol.Optional(CONF_HOUSEKEEPING_TIME, default="00:00:01"): cv.time,
571 vol.Optional(CONF_MEDIA_STORAGE_DAYS, default=7): cv.positive_int,
572 vol.Optional(CONF_DEPRECATED_ENTITIES, default=True): cv.boolean,
573})
575# The 8 keys not yet configurable via ConfigFlow - defined under the top-level `supernotify:`
576# YAML key (see CONFIG_SCHEMA/async_setup in __init__.py), never under the legacy notify platform.
577# extra=ALLOW_EXTRA matches the old PLATFORM_SCHEMA-derived schema's inherited leniency (HA's
578# base notify PLATFORM_SCHEMA itself uses ALLOW_EXTRA) - stray/unrecognized keys pass through
579# rather than failing validation.
580SUPERNOTIFY_YAML_SCHEMA: vol.Schema = vol.Schema(
581 {
582 vol.Optional(CONF_DELIVERY, default=dict): {cv.string: DELIVERY_SCHEMA},
583 vol.Optional(CONF_ACTION_GROUPS, default=dict): {
584 cv.string: vol.All([MOBILE_ACTION_SCHEMA], _single_notification_level)
585 },
586 vol.Optional(CONF_RECIPIENTS, default=list): vol.All(cv.ensure_list, [RECIPIENT_SCHEMA]),
587 vol.Optional(CONF_SCENARIOS, default=dict): vol.All(
588 {cv.string: SCENARIO_SCHEMA},
589 validate_scenario_names,
590 ),
591 vol.Optional(CONF_TRANSPORTS, default=dict): {cv.string: TRANSPORT_SCHEMA},
592 vol.Optional(CONF_CAMERAS, default=list): vol.All(cv.ensure_list, [CAMERA_SCHEMA]),
593 vol.Optional(CONF_SNOOZE, default=dict): SNOOZE_SCHEMA,
594 vol.Optional(CONF_SCENARIO_CONTROL, default=dict): SCENARIO_CONTROL_SCHEMA,
595 },
596 extra=vol.ALLOW_EXTRA,
597)
599# The 8 ConfigEntry-owned keys only (entry.data/entry.options) - deliberately separate from
600# SUPERNOTIFY_YAML_SCHEMA rather than folded into one combined schema: __init__.py's
601# Defaults for every delivery, from the Delivery Control options page - each left out when not set,
602# so deliveries keep their transport's own default
603# REMOVE_EXTRA drops options no longer offered, such as the 2.10.0 beta's apple_drop_mp4
604DELIVERY_CONTROL_SCHEMA = vol.Schema(
605 {
606 vol.Optional(CONF_DEFAULT_INCLUSION): vol.In(DEFAULT_INCLUSION_VALUES),
607 vol.Optional(CONF_VOICE_OCCUPANCY): vol.In(OCCUPANCY_VALUES),
608 },
609 extra=vol.REMOVE_EXTRA,
610)
612# _entry_full_config validates entry.data/entry.options through this schema, then merges in the
613# top-level YAML section's already-validated dict as-is. Re-validating that already-validated
614# dict a second time (as a single combined schema would require) breaks - cv.template/
615# cv.CONDITIONS_SCHEMA coerce string templates into Template objects, and reject a Template
616# object the second time round ("template value should be a string").
617CONFIG_ENTRY_SCHEMA = vol.Schema(
618 {
619 vol.Optional(CONF_DELIVERY_CONTROL, default=dict): DELIVERY_CONTROL_SCHEMA,
620 vol.Optional(CONF_TEMPLATE_PATH, default=TEMPLATE_DIR): cv.path,
621 vol.Optional(CONF_MEDIA_PATH, default=MEDIA_DIR): cv.path,
622 vol.Optional(CONF_MEDIA_URL_PREFIX, default="/supernotify/media"): cv.string,
623 vol.Optional(CONF_ARCHIVE, default={CONF_ENABLED: False}): ARCHIVE_SCHEMA,
624 vol.Optional(CONF_HOUSEKEEPING, default={}): HOUSEKEEPING_SCHEMA,
625 vol.Optional(CONF_DUPE_CHECK, default=dict): NOTIFICATION_DUPE_SCHEMA,
626 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean,
627 vol.Optional(CONF_RECIPIENTS_DISCOVERY, default=True): cv.boolean,
628 # from the entry's recipient and transport subentries, not its data or options
629 vol.Optional(CONF_RECIPIENTS, default=list): vol.All(cv.ensure_list, [RECIPIENT_SCHEMA]),
630 vol.Optional(CONF_TRANSPORTS, default=dict): {cv.string: TRANSPORT_SCHEMA},
631 },
632 extra=vol.ALLOW_EXTRA,
633)
635# Set in the integration's own config and options flows - in the top-level `supernotify:` YAML
636# they're ignored, the legacy notify platform block being the only YAML they're ever taken from
637ENTRY_OWNED_KEYS: tuple[str, ...] = (
638 CONF_DELIVERY_CONTROL,
639 CONF_TEMPLATE_PATH,
640 CONF_MEDIA_PATH,
641 CONF_MEDIA_URL_PREFIX,
642 CONF_ARCHIVE,
643 CONF_HOUSEKEEPING,
644 CONF_DUPE_CHECK,
645 CONF_MOBILE_DISCOVERY,
646 CONF_RECIPIENTS_DISCOVERY,
647)
649# The union of CONFIG_ENTRY_SCHEMA and SUPERNOTIFY_YAML_SCHEMA - for validating a single,
650# never-previously-validated raw config dict in one pass (e.g. hass_setup_lib.py's
651# TestingContext). Do NOT use this in __init__.py's _entry_full_config - see CONFIG_ENTRY_SCHEMA.
652FULL_CONFIG_SCHEMA = SUPERNOTIFY_YAML_SCHEMA.extend({
653 vol.Optional(CONF_DELIVERY_CONTROL, default=dict): DELIVERY_CONTROL_SCHEMA,
654 vol.Optional(CONF_TEMPLATE_PATH, default=TEMPLATE_DIR): cv.path,
655 vol.Optional(CONF_MEDIA_PATH, default=MEDIA_DIR): cv.path,
656 vol.Optional(CONF_MEDIA_URL_PREFIX, default="/supernotify/media"): cv.string,
657 vol.Optional(CONF_ARCHIVE, default={CONF_ENABLED: False}): ARCHIVE_SCHEMA,
658 vol.Optional(CONF_HOUSEKEEPING, default={}): HOUSEKEEPING_SCHEMA,
659 vol.Optional(CONF_DUPE_CHECK, default=dict): NOTIFICATION_DUPE_SCHEMA,
660 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean,
661 vol.Optional(CONF_RECIPIENTS_DISCOVERY, default=True): cv.boolean,
662})
665# A delivery name, a list of names to restrict to, or a mapping of names to tuning
666DELIVERY_FIELD_SCHEMA = vol.Any(cv.string, [cv.string], {cv.string: vol.Any(None, DELIVERY_CUSTOMIZE_SCHEMA)})
668_ACTION_DATA_FIELDS_SCHEMA = vol.Schema(
669 {
670 vol.Optional(ATTR_DELIVERY): DELIVERY_FIELD_SCHEMA,
671 vol.Optional(ATTR_PRIORITY): vol.Any(int, str, vol.In(list(PRIORITY_VALUES.keys()))),
672 vol.Optional(ATTR_SCENARIOS_REQUIRE): vol.All(cv.ensure_list, [cv.string]),
673 vol.Optional(ATTR_SCENARIOS_APPLY): vol.All(cv.ensure_list, [cv.string]),
674 vol.Optional(ATTR_SCENARIOS_CONSTRAIN): vol.All(cv.ensure_list, [cv.string]),
675 vol.Optional(ATTR_DELIVERY_SELECTION): vol.In(DELIVERY_SELECTION_VALUES),
676 vol.Optional(ATTR_RECIPIENTS): vol.All(cv.ensure_list, [cv.entity_id]),
677 vol.Optional(ATTR_MEDIA): MEDIA_SCHEMA,
678 vol.Optional(ATTR_MESSAGE_HTML): cv.string,
679 vol.Optional(ATTR_SPOKEN_MESSAGE): cv.string,
680 vol.Optional(ATTR_ACTION_GROUPS, default=[]): vol.All(cv.ensure_list, [cv.string]),
681 vol.Optional(ATTR_ACTIONS, default=[]): vol.All(
682 cv.ensure_list, [MOBILE_ACTION_CALL_SCHEMA], _single_notification_level
683 ),
684 vol.Optional(ATTR_DEBUG, default=False): cv.boolean,
685 vol.Optional(ATTR_FORCE_RESEND, default=False): cv.boolean,
686 vol.Optional(ATTR_DRY_RUN, default=DRY_RUN_LIVE): vol.In(DRY_RUN_VALUES),
687 vol.Optional(ATTR_DATA): vol.Any(None, DATA_SCHEMA),
688 vol.Optional(ATTR_TIMESTAMP): cv.string,
689 },
690 extra=vol.ALLOW_EXTRA, # allow other data, e.g. the android/ios mobile push
691)
693ACTION_DATA_FIELDS: frozenset[str] = frozenset(str(key) for key in _ACTION_DATA_FIELDS_SCHEMA.schema)
695ACTION_DATA_SCHEMA = vol.All(
696 cv.deprecated(key=ATTR_RECIPIENTS), # deprecated v2.2.0
697 _ACTION_DATA_FIELDS_SCHEMA,
698)
700STRICT_ACTION_DATA_SCHEMA = vol.All(
701 cv.deprecated(key=ATTR_RECIPIENTS), # deprecated v2.2.0
702 _ACTION_DATA_FIELDS_SCHEMA.extend({}, extra=vol.REMOVE_EXTRA),
703)
705# Schema for the supernotify.notify action - the same fields as ACTION_DATA_SCHEMA (normally
706# buried in notify.supernotify's generic `data:` blob), but promoted to top-level fields so
707# services.yaml can attach a selector to each one for a typed, discoverable UI in Developer
708# Tools/the action picker, rather than one opaque object field.
709NOTIFY_ACTION_SCHEMA = vol.All(
710 cv.deprecated(key=ATTR_RECIPIENTS), # deprecated v2.2.0
711 _ACTION_DATA_FIELDS_SCHEMA.extend({
712 vol.Optional(CONF_MESSAGE): cv.string,
713 vol.Optional(CONF_TITLE): cv.string,
714 vol.Optional(CONF_TARGET): ACTION_TARGET_SCHEMA,
715 # pass-through data exempt from the legacy nested-data migration - see actions.py
716 vol.Optional(ATTR_EXTRA_DATA): vol.Any(None, DATA_SCHEMA),
717 # the target selector behind CONF_TARGET can only produce entity/device/area/floor/label
718 # ids, so custom_target is a free-text escape hatch for targets it can't - e-mail
719 # addresses, phone numbers, Slack ids etc. notify.py's action_notify merges
720 # it back into target before Notification ever sees it
721 vol.Optional(ATTR_CUSTOM_TARGET): vol.All(cv.ensure_list, [cv.string]),
722 # the delivery dropdown can only give names, so this takes any form `delivery` does, and
723 # actions.py's action_notify merges the two into `delivery` before Notification sees them
724 vol.Optional(ATTR_DELIVERY_CONTROL): vol.Any(None, DELIVERY_FIELD_SCHEMA),
725 # promoted out of media: for their own selectors - notify.py's action_notify
726 # merges them back into media before Notification ever sees them
727 vol.Optional(ATTR_MEDIA_CAMERA_ENTITY_ID): cv.entity_id,
728 vol.Optional(ATTR_MEDIA_CLIP_URL): vol.Any(cv.url, cv.string),
729 vol.Optional(ATTR_MEDIA_SNAPSHOT_URL): vol.Any(cv.url, cv.string),
730 }),
731)
734def as_raw_config(value: Any) -> Any: # ruff: ignore[any-type]
735 """Validated configuration back as the plain values it was validated from, so it can be
736 stored, in a config subentry, and validated again when loaded - cv.template's Template
737 objects as their source, and the times and time periods of conditions as strings"""
738 if isinstance(value, Template):
739 return value.template
740 if isinstance(value, dict):
741 return {k: as_raw_config(v) for k, v in value.items()}
742 if isinstance(value, (list, tuple, set)):
743 return [as_raw_config(v) for v in value]
744 if isinstance(value, dt.time):
745 return value.isoformat()
746 if isinstance(value, dt.timedelta):
747 return str(value)
748 return value