Coverage for custom_components/supernotify/schema.py: 83%
47 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-02-06 15:56 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-02-06 15:56 +0000
1"""The Supernotify integration"""
3import re
4from collections.abc import Callable
5from enum import StrEnum
7import voluptuous as vol
8from homeassistant.components.notify import PLATFORM_SCHEMA
9from homeassistant.const import (
10 CONF_ACTION,
11 CONF_ALIAS,
12 CONF_CONDITION,
13 CONF_CONDITIONS,
14 CONF_DEBUG,
15 CONF_DESCRIPTION,
16 CONF_DOMAIN,
17 CONF_EMAIL,
18 CONF_ENABLED,
19 CONF_ICON,
20 CONF_ID,
21 CONF_NAME,
22 CONF_TARGET,
23 CONF_URL,
24)
25from homeassistant.helpers import config_validation as cv
26from homeassistant.helpers.typing import TemplateVarsType
28from custom_components.supernotify import MEDIA_DIR, TEMPLATE_DIR
30from .const import (
31 ATTR_ACTION,
32 ATTR_ACTION_CATEGORY,
33 ATTR_ACTION_GROUPS,
34 ATTR_ACTION_URL,
35 ATTR_ACTION_URL_TITLE,
36 ATTR_ACTIONS,
37 ATTR_DATA,
38 ATTR_DEBUG,
39 ATTR_DELIVERY,
40 ATTR_DELIVERY_SELECTION,
41 ATTR_DUPE_POLICY_MTSLP,
42 ATTR_DUPE_POLICY_NONE,
43 ATTR_EMAIL,
44 ATTR_JPEG_OPTS,
45 ATTR_MEDIA,
46 ATTR_MEDIA_CAMERA_DELAY,
47 ATTR_MEDIA_CAMERA_ENTITY_ID,
48 ATTR_MEDIA_CAMERA_PTZ_PRESET,
49 ATTR_MEDIA_CLIP_URL,
50 ATTR_MEDIA_SNAPSHOT_URL,
51 ATTR_MESSAGE_HTML,
52 ATTR_MOBILE_APP_ID,
53 ATTR_PERSON_ID,
54 ATTR_PHONE,
55 ATTR_PNG_OPTS,
56 ATTR_PRIORITY,
57 ATTR_RECIPIENTS,
58 ATTR_SCENARIOS_APPLY,
59 ATTR_SCENARIOS_CONSTRAIN,
60 ATTR_SCENARIOS_REQUIRE,
61 ATTR_TIMESTAMP,
62 ATTR_TITLE,
63 CONF_ACTION_GROUP_NAMES,
64 CONF_ACTION_GROUPS,
65 CONF_ACTION_TEMPLATE,
66 CONF_ALT_CAMERA,
67 CONF_ARCHIVE,
68 CONF_ARCHIVE_DAYS,
69 CONF_ARCHIVE_MQTT_QOS,
70 CONF_ARCHIVE_MQTT_RETAIN,
71 CONF_ARCHIVE_MQTT_TOPIC,
72 CONF_ARCHIVE_PATH,
73 CONF_ARCHIVE_PURGE_INTERVAL,
74 CONF_CAMERA,
75 CONF_CAMERAS,
76 CONF_CLASS,
77 CONF_DATA,
78 CONF_DELIVERY,
79 CONF_DELIVERY_DEFAULTS,
80 CONF_DEVICE_DISCOVERY,
81 CONF_DEVICE_DOMAIN,
82 CONF_DEVICE_MODEL_EXCLUDE,
83 CONF_DEVICE_MODEL_INCLUDE,
84 CONF_DEVICE_TRACKER,
85 CONF_DUPE_CHECK,
86 CONF_DUPE_POLICY,
87 CONF_DURATION,
88 CONF_HOUSEKEEPING,
89 CONF_HOUSEKEEPING_TIME,
90 CONF_LINKS,
91 CONF_MANUFACTURER,
92 CONF_MEDIA,
93 CONF_MEDIA_PATH,
94 CONF_MEDIA_STORAGE_DAYS,
95 CONF_MESSAGE,
96 CONF_MOBILE_APP_ID,
97 CONF_MOBILE_DEVICES,
98 CONF_MOBILE_DISCOVERY,
99 CONF_MODEL,
100 CONF_OCCUPANCY,
101 CONF_OPTIONS,
102 CONF_PERSON,
103 CONF_PHONE_NUMBER,
104 CONF_PRIORITY,
105 CONF_PTZ_CAMERA,
106 CONF_PTZ_DELAY,
107 CONF_PTZ_METHOD,
108 CONF_PTZ_PRESET_DEFAULT,
109 CONF_RECIPIENTS,
110 CONF_RECIPIENTS_DISCOVERY,
111 CONF_SCENARIOS,
112 CONF_SELECTION,
113 CONF_SELECTION_RANK,
114 CONF_SIZE,
115 CONF_TARGET_REQUIRED,
116 CONF_TARGET_USAGE,
117 CONF_TEMPLATE,
118 CONF_TEMPLATE_PATH,
119 CONF_TITLE,
120 CONF_TITLE_TEMPLATE,
121 CONF_TRANSPORT,
122 CONF_TRANSPORTS,
123 CONF_TTL,
124 CONF_TUNE,
125 CONF_URI,
126 CONF_VOLUME,
127 DELIVERY_SELECTION_VALUES,
128 OCCUPANCY_ALL,
129 OCCUPANCY_VALUES,
130 OPTION_CHIME_ALIASES,
131 OPTIONS_CHIME_DOMAINS,
132 PRIORITY_VALUES,
133 PTZ_METHOD_ONVIF,
134 PTZ_METHOD_VALUES,
135 RESERVED_DATA_KEYS,
136 RESERVED_SCENARIO_NAMES,
137 SELECTION_VALUES,
138 TARGET_REQUIRE_ALWAYS,
139 TARGET_REQUIRE_NEVER,
140 TARGET_REQUIRE_OPTIONAL,
141 TARGET_USE_FIXED,
142 TARGET_USE_MERGE_ALWAYS,
143 TARGET_USE_MERGE_ON_DELIVERY_TARGETS,
144 TARGET_USE_ON_NO_ACTION_TARGETS,
145 TARGET_USE_ON_NO_DELIVERY_TARGETS,
146 TRANSPORT_VALUES,
147)
150class SelectionRank(StrEnum):
151 FIRST = "FIRST"
152 ANY = "ANY"
153 LAST = "LAST"
156type ConditionsFunc = Callable[[TemplateVarsType], bool]
159def phone(value: str) -> str:
160 """Validate a phone number"""
161 regex = re.compile(r"^(\+\d{1,3})?\s?\(?\d{1,4}\)?[\s.-]?\d{3}[\s.-]?\d{4}$")
162 if not regex.match(value):
163 raise vol.Invalid("Invalid Phone Number")
164 return str(value)
167def validate_scenario_names(scenarios: dict) -> dict:
168 """Validate that scenario names are not reserved."""
169 for name in scenarios:
170 if name in RESERVED_SCENARIO_NAMES:
171 raise vol.Invalid(f"'{name}' is a reserved scenario name")
172 return scenarios
175# TARGET_FIELDS includes entity, device, area, floor, label ids
176TARGET_SCHEMA = vol.Any( # order of schema matters, voluptuous forces into first it finds that works
177 cv.TARGET_FIELDS
178 | {
179 vol.Optional(ATTR_EMAIL): vol.All(cv.ensure_list, [vol.Email]),
180 vol.Optional(ATTR_PHONE): vol.All(cv.ensure_list, [phone]),
181 vol.Optional(ATTR_MOBILE_APP_ID): vol.All(cv.ensure_list, [cv.service]),
182 vol.Optional(ATTR_PERSON_ID): vol.All(cv.ensure_list, [cv.entity_id]),
183 vol.Optional(cv.string): vol.All(cv.ensure_list, [str]),
184 },
185 str,
186 list[str],
187)
189DATA_SCHEMA = vol.Schema({vol.NotIn(RESERVED_DATA_KEYS): vol.Any(str, int, bool, float, dict, list)})
191MOBILE_DEVICE_SCHEMA = vol.Schema({
192 vol.Optional(CONF_MANUFACTURER): cv.string,
193 vol.Optional(CONF_MODEL): cv.string,
194 vol.Optional(CONF_CLASS): cv.string,
195 vol.Optional(CONF_MOBILE_APP_ID): cv.string,
196 vol.Optional(CONF_DEVICE_TRACKER): cv.entity_id,
197 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
198})
199NOTIFICATION_DUPE_SCHEMA = vol.Schema({
200 vol.Optional(CONF_TTL): cv.positive_int,
201 vol.Optional(CONF_SIZE, default=100): cv.positive_int,
202 vol.Optional(CONF_DUPE_POLICY, default=ATTR_DUPE_POLICY_MTSLP): vol.In([ATTR_DUPE_POLICY_MTSLP, ATTR_DUPE_POLICY_NONE]),
203})
206DELIVERY_CUSTOMIZE_SCHEMA = vol.All(
207 vol.Schema(
208 {
209 vol.Optional(CONF_TARGET): TARGET_SCHEMA,
210 vol.Optional(CONF_ENABLED): vol.Any(None, cv.boolean),
211 vol.Optional(CONF_DATA): DATA_SCHEMA,
212 },
213 ),
214)
215LINK_SCHEMA = vol.Schema({
216 vol.Required(CONF_URL): cv.url,
217 vol.Required(CONF_DESCRIPTION): cv.string,
218 vol.Optional(CONF_ID): cv.string,
219 vol.Optional(CONF_ICON): cv.icon,
220 vol.Optional(CONF_NAME): cv.string,
221})
224DELIVERY_CONFIG_SCHEMA = vol.Schema({ # shared by Transport Defaults and Delivery definitions
225 # defaults set in model.DeliveryConfig
226 vol.Optional(CONF_ACTION): cv.service, # previously 'service:'
227 vol.Optional(CONF_DEBUG): cv.boolean,
228 vol.Optional(CONF_OPTIONS): dict, # transport tuning
229 vol.Optional(CONF_DATA): DATA_SCHEMA,
230 vol.Optional(CONF_TARGET): TARGET_SCHEMA,
231 vol.Optional(CONF_TARGET_REQUIRED): vol.Any(
232 cv.boolean, vol.In([TARGET_REQUIRE_ALWAYS, TARGET_REQUIRE_NEVER, TARGET_REQUIRE_OPTIONAL])
233 ),
234 vol.Optional(CONF_TARGET_USAGE): vol.In([
235 TARGET_USE_ON_NO_DELIVERY_TARGETS,
236 TARGET_USE_ON_NO_ACTION_TARGETS,
237 TARGET_USE_MERGE_ON_DELIVERY_TARGETS,
238 TARGET_USE_MERGE_ALWAYS,
239 TARGET_USE_FIXED,
240 ]),
241 vol.Optional(CONF_SELECTION): vol.All(cv.ensure_list, [vol.In(SELECTION_VALUES)]),
242 vol.Optional(CONF_PRIORITY): vol.All(cv.ensure_list, [vol.Any(int, str, vol.In(list(PRIORITY_VALUES.keys())))]),
243 vol.Optional(CONF_SELECTION_RANK): vol.In([
244 SelectionRank.ANY,
245 SelectionRank.FIRST,
246 SelectionRank.LAST,
247 ]),
248})
251DELIVERY_SCHEMA = vol.All(
252 cv.deprecated(key=CONF_CONDITION),
253 DELIVERY_CONFIG_SCHEMA.extend({
254 vol.Required(CONF_TRANSPORT): vol.In(TRANSPORT_VALUES),
255 vol.Optional(CONF_ALIAS): cv.string,
256 vol.Optional(CONF_TEMPLATE): cv.string,
257 vol.Optional(CONF_MESSAGE): vol.Any(None, cv.string),
258 vol.Optional(CONF_TITLE): vol.Any(None, cv.string),
259 vol.Optional(CONF_ENABLED): cv.boolean,
260 vol.Optional(CONF_OCCUPANCY, default=OCCUPANCY_ALL): vol.In(OCCUPANCY_VALUES),
261 vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA,
262 vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA,
263 }),
264)
266TRANSPORT_SCHEMA = vol.All(
267 cv.deprecated(key=CONF_DEVICE_DOMAIN), # deprecated v1.9.0
268 cv.deprecated(key=CONF_DEVICE_DISCOVERY), # deprecated v1.9.0
269 cv.deprecated(key=CONF_DEVICE_MODEL_INCLUDE), # deprecated v1.9.0
270 cv.deprecated(key=CONF_DEVICE_MODEL_EXCLUDE), # deprecated v1.9.0
271 vol.Schema({
272 vol.Optional(CONF_ALIAS): cv.string,
273 vol.Optional(CONF_DEVICE_DOMAIN): vol.All(cv.ensure_list, [cv.string]),
274 vol.Optional(CONF_DEVICE_MODEL_INCLUDE): vol.All(cv.ensure_list, [cv.string]),
275 vol.Optional(CONF_DEVICE_MODEL_EXCLUDE): vol.All(cv.ensure_list, [cv.string]),
276 vol.Optional(CONF_DEVICE_DISCOVERY): cv.boolean,
277 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
278 vol.Optional(CONF_DELIVERY_DEFAULTS): DELIVERY_CONFIG_SCHEMA,
279 }),
280)
281# Idea - differentiate enabled as recipient vs as occupant, for ALL_IN etc check
282# May need condition, and also enabled if delivery disabled
283# CONF_OCCUPANCY="occupancy"
284# OPTION_OCCUPANCY_DEFAULT="default"
285# OPTIONS_OCCUPANCY=[OPTION_OCCUPANCY_DEFAULT,OPTION_OCCUPANCY_EXCLUDE]
286# OPTION_OCCUPANCY_EXCLUDE="exclude"
289RECIPIENT_SCHEMA = vol.Schema({
290 vol.Required(CONF_PERSON): cv.entity_id,
291 vol.Optional(CONF_ALIAS): cv.string,
292 vol.Optional(CONF_EMAIL): cv.string,
293 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
294 # vol.Optional(CONF_OCCUPANCY,default=OPTION_OCCUPANCY_DEFAULT):vol.In(OPTIONS_OCCUPANCY),
295 vol.Optional(CONF_TARGET): TARGET_SCHEMA,
296 vol.Optional(CONF_PHONE_NUMBER): cv.string,
297 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean,
298 vol.Optional(CONF_MOBILE_DEVICES, default=list): vol.All(cv.ensure_list, [MOBILE_DEVICE_SCHEMA]),
299 vol.Optional(CONF_DELIVERY, default=dict): {cv.string: DELIVERY_CUSTOMIZE_SCHEMA},
300})
301CAMERA_SCHEMA = vol.Schema({
302 vol.Required(CONF_CAMERA): cv.entity_id,
303 vol.Optional(CONF_ALT_CAMERA): vol.All(cv.ensure_list, [cv.entity_id]),
304 vol.Optional(CONF_ALIAS): cv.string,
305 vol.Optional(CONF_URL): cv.url,
306 vol.Optional(CONF_DEVICE_TRACKER): cv.entity_id,
307 vol.Optional(CONF_PTZ_CAMERA): cv.entity_id,
308 vol.Optional(CONF_PTZ_PRESET_DEFAULT, default=1): vol.Any(cv.positive_int, cv.string),
309 vol.Optional(CONF_PTZ_DELAY, default=0): int,
310 vol.Optional(CONF_PTZ_METHOD, default=PTZ_METHOD_ONVIF): vol.In(PTZ_METHOD_VALUES),
311})
312MEDIA_SCHEMA = vol.Schema({
313 vol.Optional(ATTR_MEDIA_CAMERA_ENTITY_ID): cv.entity_id,
314 vol.Optional(ATTR_MEDIA_CAMERA_DELAY, default=0): int,
315 vol.Optional(ATTR_MEDIA_CAMERA_PTZ_PRESET): vol.Any(cv.positive_int, cv.string),
316 # URL fragments allowed
317 vol.Optional(ATTR_MEDIA_CLIP_URL): vol.Any(cv.url, cv.string),
318 vol.Optional(ATTR_MEDIA_SNAPSHOT_URL): vol.Any(cv.url, cv.string),
319 vol.Optional(ATTR_JPEG_OPTS): dict,
320 vol.Optional(ATTR_PNG_OPTS): dict,
321})
324SCENARIO_SCHEMA = vol.All(
325 cv.deprecated(key=CONF_CONDITION),
326 cv.deprecated(key="delivery_selection"),
327 vol.Schema({
328 vol.Optional(CONF_ALIAS): cv.string,
329 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
330 vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA,
331 vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA,
332 vol.Optional(CONF_MEDIA): MEDIA_SCHEMA,
333 vol.Optional(CONF_ACTION_GROUP_NAMES, default=[]): vol.All(cv.ensure_list, [cv.string]),
334 vol.Optional("delivery_selection"): cv.string,
335 vol.Optional(CONF_DELIVERY, default=dict): {cv.string: vol.Any(None, DELIVERY_CUSTOMIZE_SCHEMA)},
336 }),
337)
338MOBILE_ACTION_CALL_SCHEMA = vol.Schema(
339 {
340 vol.Optional(ATTR_ACTION): cv.string,
341 vol.Optional(ATTR_TITLE): cv.string,
342 vol.Optional(ATTR_ACTION_CATEGORY): cv.string,
343 vol.Optional(ATTR_ACTION_URL): cv.url,
344 vol.Optional(ATTR_ACTION_URL_TITLE): cv.string,
345 },
346 extra=vol.ALLOW_EXTRA,
347)
348MOBILE_ACTION_SCHEMA = vol.Schema(
349 {
350 vol.Exclusive(CONF_ACTION, CONF_ACTION_TEMPLATE): cv.string,
351 vol.Exclusive(CONF_TITLE, CONF_TITLE_TEMPLATE): cv.string,
352 vol.Optional(CONF_URI): cv.url,
353 vol.Optional(CONF_ICON): cv.string,
354 },
355 extra=vol.ALLOW_EXTRA,
356)
359ARCHIVE_SCHEMA = vol.Schema({
360 vol.Optional(CONF_ARCHIVE_PATH): cv.path,
361 vol.Optional(CONF_ENABLED, default=False): cv.boolean,
362 vol.Optional(CONF_ARCHIVE_DAYS, default=3): cv.positive_int,
363 vol.Optional(CONF_ARCHIVE_MQTT_TOPIC): cv.string,
364 vol.Optional(CONF_ARCHIVE_MQTT_QOS, default=0): cv.positive_int,
365 vol.Optional(CONF_ARCHIVE_MQTT_RETAIN, default=True): cv.boolean,
366 vol.Optional(CONF_ARCHIVE_PURGE_INTERVAL, default=60): cv.positive_int,
367 vol.Optional(CONF_DEBUG, default=False): cv.boolean,
368})
370HOUSEKEEPING_SCHEMA = vol.Schema({
371 vol.Optional(CONF_HOUSEKEEPING_TIME, default="00:00:01"): cv.time,
372 vol.Optional(CONF_MEDIA_STORAGE_DAYS, default=7): cv.positive_int,
373})
375PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
376 vol.Optional(CONF_TEMPLATE_PATH, default=TEMPLATE_DIR): cv.path,
377 vol.Optional(CONF_MEDIA_PATH, default=MEDIA_DIR): cv.path,
378 vol.Optional(CONF_ARCHIVE, default={CONF_ENABLED: False}): ARCHIVE_SCHEMA,
379 vol.Optional(CONF_HOUSEKEEPING, default={}): HOUSEKEEPING_SCHEMA,
380 vol.Optional(CONF_DUPE_CHECK, default=dict): NOTIFICATION_DUPE_SCHEMA,
381 vol.Optional(CONF_DELIVERY, default=dict): {cv.string: DELIVERY_SCHEMA},
382 vol.Optional(CONF_ACTION_GROUPS, default=dict): {cv.string: [MOBILE_ACTION_SCHEMA]},
383 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean,
384 vol.Optional(CONF_RECIPIENTS_DISCOVERY, default=True): cv.boolean,
385 vol.Optional(CONF_RECIPIENTS, default=list): vol.All(cv.ensure_list, [RECIPIENT_SCHEMA]),
386 vol.Optional(CONF_LINKS, default=list): vol.All(cv.ensure_list, [LINK_SCHEMA]),
387 vol.Optional(CONF_SCENARIOS, default=dict): vol.All(
388 {cv.string: SCENARIO_SCHEMA},
389 validate_scenario_names,
390 ),
391 vol.Optional(CONF_TRANSPORTS, default=dict): {cv.string: TRANSPORT_SCHEMA},
392 vol.Optional(CONF_CAMERAS, default=list): vol.All(cv.ensure_list, [CAMERA_SCHEMA]),
393})
394SUPERNOTIFY_SCHEMA = PLATFORM_SCHEMA
396CHIME_ALIASES_SCHEMA = vol.Schema({
397 vol.Required(OPTION_CHIME_ALIASES, default=dict): vol.Schema({
398 cv.string: vol.Schema({
399 cv.string: vol.Any(
400 vol.Any(None, cv.string, vol.In(OPTIONS_CHIME_DOMAINS)),
401 vol.Schema({
402 vol.Optional(CONF_ALIAS): cv.string,
403 vol.Optional(CONF_DOMAIN): cv.string,
404 vol.Optional(CONF_TUNE): cv.string,
405 vol.Optional(CONF_DATA): DATA_SCHEMA,
406 vol.Optional(CONF_VOLUME): float,
407 vol.Optional(CONF_TARGET): TARGET_SCHEMA,
408 vol.Optional(CONF_DURATION): cv.positive_int,
409 }),
410 )
411 })
412 })
413})
416ACTION_DATA_SCHEMA = vol.Schema(
417 {
418 vol.Optional(ATTR_DELIVERY): vol.Any(cv.string, [cv.string], {cv.string: vol.Any(None, DELIVERY_CUSTOMIZE_SCHEMA)}),
419 vol.Optional(ATTR_PRIORITY): vol.Any(int, str, vol.In(list(PRIORITY_VALUES.keys()))),
420 vol.Optional(ATTR_SCENARIOS_REQUIRE): vol.All(cv.ensure_list, [cv.string]),
421 vol.Optional(ATTR_SCENARIOS_APPLY): vol.All(cv.ensure_list, [cv.string]),
422 vol.Optional(ATTR_SCENARIOS_CONSTRAIN): vol.All(cv.ensure_list, [cv.string]),
423 vol.Optional(ATTR_DELIVERY_SELECTION): vol.In(DELIVERY_SELECTION_VALUES),
424 vol.Optional(ATTR_RECIPIENTS): vol.All(cv.ensure_list, [cv.entity_id]),
425 vol.Optional(ATTR_MEDIA): MEDIA_SCHEMA,
426 vol.Optional(ATTR_MESSAGE_HTML): cv.string,
427 vol.Optional(ATTR_ACTION_GROUPS, default=[]): vol.All(cv.ensure_list, [cv.string]),
428 vol.Optional(ATTR_ACTIONS, default=[]): vol.All(cv.ensure_list, [MOBILE_ACTION_CALL_SCHEMA]),
429 vol.Optional(ATTR_DEBUG, default=False): cv.boolean,
430 vol.Optional(ATTR_DATA): vol.Any(None, DATA_SCHEMA),
431 vol.Optional(ATTR_TIMESTAMP): cv.string,
432 },
433 extra=vol.ALLOW_EXTRA, # allow other data, e.g. the android/ios mobile push
434)
436STRICT_ACTION_DATA_SCHEMA = ACTION_DATA_SCHEMA.extend({}, extra=vol.REMOVE_EXTRA)