Coverage for custom_components/supernotify/schema.py: 95%

96 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 21:14 +0000

1"""The Supernotify integration""" 

2 

3import re 

4from collections.abc import Callable 

5from enum import IntFlag, StrEnum, auto 

6 

7import voluptuous as vol 

8from homeassistant.const import ( 

9 CONF_ACTION, 

10 CONF_ALIAS, 

11 CONF_CONDITION, 

12 CONF_CONDITIONS, 

13 CONF_DEBUG, 

14 CONF_DESCRIPTION, 

15 CONF_EMAIL, 

16 CONF_ENABLED, 

17 CONF_HOST, 

18 CONF_ICON, 

19 CONF_ID, 

20 CONF_NAME, 

21 CONF_PASSWORD, 

22 CONF_PORT, 

23 CONF_TARGET, 

24 CONF_TIMEOUT, 

25 CONF_URL, 

26 CONF_USERNAME, 

27 CONF_VERIFY_SSL, 

28) 

29from homeassistant.helpers import config_validation as cv 

30from homeassistant.helpers.typing import TemplateVarsType 

31 

32from custom_components.supernotify import ARCHIVE_DIR, MEDIA_DIR, TEMPLATE_DIR 

33 

34from .const import ( 

35 ATTR_ACTION, 

36 ATTR_ACTION_GROUPS, 

37 ATTR_ACTION_URL, 

38 ATTR_ACTION_URL_TITLE, 

39 ATTR_ACTIONS, 

40 ATTR_CUSTOM_TARGET, 

41 ATTR_DATA, 

42 ATTR_DEBUG, 

43 ATTR_DELIVERY, 

44 ATTR_DELIVERY_CONTROL, 

45 ATTR_DELIVERY_SELECTION, 

46 ATTR_DUPE_POLICY_MT, 

47 ATTR_DUPE_POLICY_MTSLP, 

48 ATTR_DUPE_POLICY_NONE, 

49 ATTR_EMAIL, 

50 ATTR_EXTRA_DATA, 

51 ATTR_FORCE_RESEND, 

52 ATTR_JPEG_OPTS, 

53 ATTR_MEDIA, 

54 ATTR_MEDIA_CAMERA_DELAY, 

55 ATTR_MEDIA_CAMERA_ENTITY_ID, 

56 ATTR_MEDIA_CAMERA_PTZ_PRESET, 

57 ATTR_MEDIA_CLIP_URL, 

58 ATTR_MEDIA_SNAPSHOT_PATH, 

59 ATTR_MEDIA_SNAPSHOT_URL, 

60 ATTR_MESSAGE_HTML, 

61 ATTR_MOBILE_APP_ID, 

62 ATTR_PERSON_ID, 

63 ATTR_PHONE, 

64 ATTR_PNG_OPTS, 

65 ATTR_PRIORITY, 

66 ATTR_RECIPIENTS, 

67 ATTR_SCENARIOS_APPLY, 

68 ATTR_SCENARIOS_CONSTRAIN, 

69 ATTR_SCENARIOS_REQUIRE, 

70 ATTR_SPOKEN_MESSAGE, 

71 ATTR_TIMESTAMP, 

72 ATTR_TITLE, 

73 CONF_ACTION_GROUP_NAMES, 

74 CONF_ACTION_GROUPS, 

75 CONF_ACTION_TEMPLATE, 

76 CONF_ALT_CAMERA, 

77 CONF_ARCHIVE, 

78 CONF_ARCHIVE_DAYS, 

79 CONF_ARCHIVE_DIAGNOSTICS, 

80 CONF_ARCHIVE_EVENT_NAME, 

81 CONF_ARCHIVE_EVENT_SELECTION, 

82 CONF_ARCHIVE_MQTT_QOS, 

83 CONF_ARCHIVE_MQTT_RETAIN, 

84 CONF_ARCHIVE_MQTT_TOPIC, 

85 CONF_ARCHIVE_PATH, 

86 CONF_ARCHIVE_PURGE_INTERVAL, 

87 CONF_CAMERA, 

88 CONF_CAMERAS, 

89 CONF_CLASS, 

90 CONF_CONNECTION, 

91 CONF_DATA, 

92 CONF_DEFAULT_INCLUSION, 

93 CONF_DELIVERY, 

94 CONF_DELIVERY_CONTROL, 

95 CONF_DELIVERY_DEFAULTS, 

96 CONF_DEVICE_DISCOVERY, 

97 CONF_DEVICE_DOMAIN, 

98 CONF_DEVICE_MODEL_EXCLUDE, 

99 CONF_DEVICE_MODEL_INCLUDE, 

100 CONF_DEVICE_TRACKER, 

101 CONF_DUPE_CHECK, 

102 CONF_DUPE_POLICY, 

103 CONF_ENCRYPTION, 

104 CONF_EXPOSE_STATE, 

105 CONF_HOUSEKEEPING, 

106 CONF_HOUSEKEEPING_TIME, 

107 CONF_INCLUSION, 

108 CONF_LINKS, 

109 CONF_LOAD, 

110 CONF_MANUFACTURER, 

111 CONF_MEDIA, 

112 CONF_MEDIA_PATH, 

113 CONF_MEDIA_STORAGE_DAYS, 

114 CONF_MEDIA_URL_PREFIX, 

115 CONF_MESSAGE, 

116 CONF_MOBILE_APP_ID, 

117 CONF_MOBILE_DEVICES, 

118 CONF_MOBILE_DISCOVERY, 

119 CONF_MODEL, 

120 CONF_OCCUPANCY, 

121 CONF_OPTIONS, 

122 CONF_PERSON, 

123 CONF_PHONE_NUMBER, 

124 CONF_PRIORITY, 

125 CONF_PTZ_CAMERA, 

126 CONF_PTZ_DELAY, 

127 CONF_PTZ_METHOD, 

128 CONF_PTZ_PRESET_DEFAULT, 

129 CONF_RECIPIENTS, 

130 CONF_RECIPIENTS_DISCOVERY, 

131 CONF_REFRESH, 

132 CONF_REFRESH_INTERVAL, 

133 CONF_SCENARIO_CONTROL, 

134 CONF_SCENARIOS, 

135 CONF_SELECTION, 

136 CONF_SELECTION_RANK, 

137 CONF_SIZE, 

138 CONF_SNAP_WAIT, 

139 CONF_SNOOZE, 

140 CONF_SNOOZE_TIME, 

141 CONF_TARGET_REQUIRED, 

142 CONF_TARGET_USAGE, 

143 CONF_TEMPLATE, 

144 CONF_TEMPLATE_PATH, 

145 CONF_TITLE, 

146 CONF_TITLE_TEMPLATE, 

147 CONF_TRANSPORT, 

148 CONF_TRANSPORTS, 

149 CONF_TTL, 

150 CONF_URI, 

151 CONF_USER_ID, 

152 CONF_VOICE_OCCUPANCY, 

153 DEFAULT_INCLUSION_VALUES, 

154 DELIVERY_SELECTION_VALUES, 

155 INCLUSION_VALUES, 

156 OCCUPANCY_VALUES, 

157 PRIORITY_VALUES, 

158 PTZ_DELAY_DEFAULT, 

159 PTZ_METHOD_ONVIF, 

160 PTZ_METHOD_VALUES, 

161 RESERVED_DATA_KEYS, 

162 RESERVED_SCENARIO_NAMES, 

163 SCENARIO_STATE_REFRESH_DEFAULT, 

164 SNAP_WAIT_DEFAULT, 

165 TARGET_REQUIRE_ALWAYS, 

166 TARGET_REQUIRE_NEVER, 

167 TARGET_REQUIRE_OPTIONAL, 

168 TARGET_USE_FIXED, 

169 TARGET_USE_MERGE_ALWAYS, 

170 TARGET_USE_MERGE_ON_DELIVERY_TARGETS, 

171 TARGET_USE_ON_NO_ACTION_TARGETS, 

172 TARGET_USE_ON_NO_DELIVERY_TARGETS, 

173 TRANSPORT_VALUES, 

174) 

175 

176 

177class OutcomeSelection(IntFlag): 

178 NONE = 0 

179 SUCCESS = 1 

180 NO_DELIVERY = 2 

181 PARTIAL_DELIVERY = 4 

182 FALLBACK_DELIVERY = 8 

183 ERROR = 16 

184 DUPE = 32 

185 ALL = 65536 

186 

187 

188def parse_event_policy(value: object) -> OutcomeSelection: 

189 if isinstance(value, OutcomeSelection): 

190 return value 

191 if isinstance(value, int): 

192 return OutcomeSelection(value) 

193 if isinstance(value, str): 

194 result = OutcomeSelection.NONE 

195 for part in value.split("|"): 

196 result |= OutcomeSelection[part.strip()] 

197 return result 

198 raise vol.Invalid(f"expected OutcomeSelection, got {value!r}") 

199 

200 

201class DeliveryOutcome(StrEnum): 

202 SUCCESS = auto() 

203 NO_DELIVERY = auto() 

204 PARTIAL_DELIVERY = auto() 

205 FALLBACK_DELIVERY = auto() 

206 ERROR = auto() 

207 DUPE = auto() 

208 

209 

210class EnvelopeOutcome(StrEnum): 

211 SUCCESS = auto() 

212 ERROR = auto() 

213 SKIPPED = auto() # envelope not generated 

214 SUPPRESSED = auto() # envelope generated but intentionally not delivered 

215 

216 

217class SelectionRank(StrEnum): 

218 FIRST = "FIRST" 

219 ANY = "ANY" 

220 LAST = "LAST" 

221 

222 

223type ConditionsFunc = Callable[[TemplateVarsType], bool] 

224 

225 

226def phone(value: str) -> str: 

227 """Validate a phone number""" 

228 regex = re.compile(r"^(\+\d{1,3})?\s?\(?\d{1,4}\)?[\s.-]?\d{3}[\s.-]?\d{4}$") 

229 if not regex.match(value): 

230 raise vol.Invalid("Invalid Phone Number") 

231 return str(value) 

232 

233 

234def validate_scenario_names(scenarios: dict) -> dict: 

235 """Validate that scenario names are not reserved.""" 

236 for name in scenarios: 

237 if name in RESERVED_SCENARIO_NAMES: 

238 raise vol.Invalid(f"'{name}' is a reserved scenario name") 

239 return scenarios 

240 

241 

242# TARGET_FIELDS includes entity, device, area, floor, label ids 

243TARGET_SCHEMA = vol.Any( # order of schema matters, voluptuous forces into first it finds that works 

244 cv.TARGET_FIELDS 

245 | { 

246 vol.Optional(ATTR_EMAIL): vol.All(cv.ensure_list, [vol.Email()]), # type: ignore[call-arg] # ty: ignore[missing-argument] 

247 vol.Optional(ATTR_PHONE): vol.All(cv.ensure_list, [phone]), 

248 vol.Optional(ATTR_MOBILE_APP_ID): vol.All(cv.ensure_list, [cv.service]), 

249 vol.Optional(ATTR_PERSON_ID): vol.All(cv.ensure_list, [cv.entity_id]), 

250 vol.Optional(cv.string): vol.All(cv.ensure_list, [str]), 

251 }, 

252 str, 

253 list[str], 

254) 

255 

256DATA_SCHEMA = vol.Schema({vol.NotIn(RESERVED_DATA_KEYS): vol.Any(None, str, int, bool, float, dict, list)}) 

257 

258MOBILE_DEVICE_SCHEMA = vol.Schema({ 

259 vol.Optional(CONF_MANUFACTURER): cv.string, 

260 vol.Optional(CONF_MODEL): cv.string, 

261 vol.Optional(CONF_CLASS): cv.string, 

262 vol.Optional(CONF_MOBILE_APP_ID): cv.string, 

263 vol.Optional(CONF_DEVICE_TRACKER): cv.entity_id, 

264 vol.Optional(CONF_ENABLED, default=True): cv.boolean, 

265}) 

266NOTIFICATION_DUPE_SCHEMA = vol.Schema({ 

267 vol.Optional(CONF_TTL, default=120): cv.positive_int, 

268 vol.Optional(CONF_SIZE, default=100): cv.positive_int, 

269 vol.Optional(CONF_DUPE_POLICY, default=ATTR_DUPE_POLICY_MTSLP): vol.In([ 

270 ATTR_DUPE_POLICY_MTSLP, 

271 ATTR_DUPE_POLICY_MT, 

272 ATTR_DUPE_POLICY_NONE, 

273 ]), 

274}) 

275 

276 

277DELIVERY_CUSTOMIZE_SCHEMA = vol.All( 

278 vol.Schema( 

279 { 

280 vol.Optional(CONF_TARGET): TARGET_SCHEMA, 

281 vol.Optional(CONF_ENABLED): vol.Any(None, cv.boolean), 

282 vol.Optional(CONF_DATA): DATA_SCHEMA, 

283 }, 

284 ), 

285) 

286LINK_SCHEMA = vol.Schema({ 

287 vol.Required(CONF_URL): cv.url, 

288 vol.Required(CONF_DESCRIPTION): cv.string, 

289 vol.Optional(CONF_ID): cv.string, 

290 vol.Optional(CONF_ICON): cv.icon, 

291 vol.Optional(CONF_NAME): cv.string, 

292}) 

293 

294# global scenario control options and defaults, advanced config, YAML only 

295SCENARIO_CONTROL_SCHEMA = vol.Schema({ 

296 # Evaluating scenario conditions to publish binary_sensor state costs whatever the 

297 # conditions cost, which for template-heavy scenarios is not free on small hardware. 

298 vol.Optional(CONF_REFRESH, default=False): cv.boolean, 

299 # Sweep every scenario this often, for conditions no entity change can announce (time 

300 # windows, sun, templates whose dependencies could not be extracted). 0 disables the 

301 # sweep and leaves state entirely event driven. 

302 vol.Optional(CONF_REFRESH_INTERVAL, default=SCENARIO_STATE_REFRESH_DEFAULT): cv.positive_int, 

303}) 

304 

305SNOOZE_SCHEMA = vol.Schema({vol.Optional(CONF_SNOOZE_TIME, default=60 * 60): cv.positive_int}) 

306 

307DELIVERY_CONFIG_SCHEMA = vol.Schema({ # shared by Transport Defaults and Delivery definitions 

308 # defaults set in model.DeliveryConfig 

309 vol.Optional(CONF_ACTION): cv.service, # previously 'service:' 

310 vol.Optional(CONF_DEBUG): cv.boolean, 

311 vol.Optional(CONF_OPTIONS): dict, # transport tuning 

312 vol.Optional(CONF_DATA): DATA_SCHEMA, 

313 vol.Optional(CONF_TARGET): TARGET_SCHEMA, 

314 vol.Optional(CONF_TARGET_REQUIRED): vol.Any( 

315 cv.boolean, vol.In([TARGET_REQUIRE_ALWAYS, TARGET_REQUIRE_NEVER, TARGET_REQUIRE_OPTIONAL]) 

316 ), 

317 vol.Optional(CONF_TARGET_USAGE): vol.In([ 

318 TARGET_USE_ON_NO_DELIVERY_TARGETS, 

319 TARGET_USE_ON_NO_ACTION_TARGETS, 

320 TARGET_USE_MERGE_ON_DELIVERY_TARGETS, 

321 TARGET_USE_MERGE_ALWAYS, 

322 TARGET_USE_FIXED, 

323 ]), 

324 vol.Optional(CONF_INCLUSION): vol.All(cv.ensure_list, [vol.In(INCLUSION_VALUES)]), 

325 vol.Optional(CONF_PRIORITY): vol.All(cv.ensure_list, [vol.Any(int, str, vol.In(list(PRIORITY_VALUES.keys())))]), 

326 vol.Optional(CONF_SELECTION_RANK): vol.In([ 

327 SelectionRank.ANY, 

328 SelectionRank.FIRST, 

329 SelectionRank.LAST, 

330 ]), 

331 # also settable at Delivery level - a Transport's delivery_defaults let these be 

332 # configured once for its auto-generated delivery, without needing an explicit Delivery 

333 vol.Optional(CONF_ALIAS): cv.string, 

334 vol.Optional(CONF_TEMPLATE): cv.string, 

335 vol.Optional(CONF_MESSAGE): vol.Any(None, cv.string), 

336 vol.Optional(CONF_TITLE): vol.Any(None, cv.string), 

337 # no default here, which would hide a transport's own - DeliveryConfig falls back to OCCUPANCY_ALL 

338 vol.Optional(CONF_OCCUPANCY): vol.In(OCCUPANCY_VALUES), 

339 vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, 

340}) 

341 

342 

343def _migrate_condition(config: dict) -> dict: 

344 """Migrate deprecated 'condition' key to 'conditions'.""" 

345 if CONF_CONDITION in config: 

346 if CONF_CONDITIONS not in config: 

347 config = {**config, CONF_CONDITIONS: config[CONF_CONDITION]} 

348 config = {k: v for k, v in config.items() if k != CONF_CONDITION} 

349 return config 

350 

351 

352DELIVERY_SCHEMA = vol.All( 

353 _migrate_condition, 

354 cv.deprecated(key=CONF_SELECTION, replacement_key=CONF_INCLUSION), 

355 DELIVERY_CONFIG_SCHEMA.extend({ 

356 vol.Required(CONF_TRANSPORT): vol.In(TRANSPORT_VALUES), 

357 vol.Optional(CONF_ENABLED): cv.boolean, 

358 }), 

359) 

360 

361CONNECTION_SCHEMA = vol.Schema({ 

362 vol.Required(CONF_HOST): cv.string, 

363 vol.Optional(CONF_PORT, default=587): cv.port, 

364 vol.Optional(CONF_ENCRYPTION, default="starttls"): vol.In(["tls", "starttls", "none"]), 

365 vol.Optional(CONF_USERNAME): cv.string, 

366 vol.Optional(CONF_PASSWORD): cv.string, 

367 vol.Optional(CONF_TIMEOUT, default=5): cv.positive_int, 

368 vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, 

369}) 

370 

371TRANSPORT_SCHEMA = vol.All( 

372 cv.deprecated(key=CONF_DEVICE_DOMAIN), # deprecated v1.9.0 

373 cv.deprecated(key=CONF_DEVICE_DISCOVERY), # deprecated v1.9.0 

374 cv.deprecated(key=CONF_DEVICE_MODEL_INCLUDE), # deprecated v1.9.0 

375 cv.deprecated(key=CONF_DEVICE_MODEL_EXCLUDE), # deprecated v1.9.0 

376 vol.Schema({ 

377 vol.Optional(CONF_ALIAS): cv.string, 

378 vol.Optional(CONF_LOAD, default=True): cv.boolean, 

379 vol.Optional(CONF_ENABLED, default=True): cv.boolean, 

380 vol.Optional(CONF_DELIVERY_DEFAULTS): vol.All( 

381 cv.deprecated(key=CONF_SELECTION, replacement_key=CONF_INCLUSION), DELIVERY_CONFIG_SCHEMA 

382 ), 

383 # only meaningful for transports (eg smtp) that own their own network connection 

384 vol.Optional(CONF_CONNECTION): CONNECTION_SCHEMA, 

385 # deprecated, replaced by options usage 

386 vol.Optional(CONF_DEVICE_DOMAIN): vol.All(cv.ensure_list, [cv.string]), 

387 vol.Optional(CONF_DEVICE_MODEL_INCLUDE): vol.All(cv.ensure_list, [cv.string]), 

388 vol.Optional(CONF_DEVICE_MODEL_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), 

389 vol.Optional(CONF_DEVICE_DISCOVERY): cv.boolean, 

390 }), 

391) 

392# Idea - differentiate enabled as recipient vs as occupant, for ALL_IN etc check 

393# May need condition, and also enabled if delivery disabled 

394# CONF_OCCUPANCY="occupancy" 

395# OPTION_OCCUPANCY_DEFAULT="default" 

396# OPTIONS_OCCUPANCY=[OPTION_OCCUPANCY_DEFAULT,OPTION_OCCUPANCY_EXCLUDE] 

397# OPTION_OCCUPANCY_EXCLUDE="exclude" 

398 

399 

400RECIPIENT_SCHEMA = vol.All( 

401 vol.Schema({ 

402 vol.Optional(CONF_PERSON): cv.entity_id, 

403 vol.Optional(CONF_USER_ID): cv.string, 

404 vol.Optional(CONF_ALIAS): cv.string, 

405 vol.Optional(CONF_EMAIL): cv.string, 

406 vol.Optional(CONF_ENABLED, default=True): cv.boolean, 

407 # vol.Optional(CONF_OCCUPANCY,default=OPTION_OCCUPANCY_DEFAULT):vol.In(OPTIONS_OCCUPANCY), 

408 vol.Optional(CONF_TARGET): TARGET_SCHEMA, 

409 vol.Optional(CONF_PHONE_NUMBER): cv.string, 

410 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean, 

411 vol.Optional(CONF_MOBILE_DEVICES, default=list): vol.All(cv.ensure_list, [MOBILE_DEVICE_SCHEMA]), 

412 vol.Optional(CONF_DELIVERY, default=dict): {cv.string: DELIVERY_CUSTOMIZE_SCHEMA}, 

413 }), 

414 cv.has_at_least_one_key(CONF_PERSON, CONF_USER_ID), 

415) 

416CAMERA_SCHEMA = vol.Schema({ 

417 vol.Required(CONF_CAMERA): cv.entity_id, 

418 vol.Optional(CONF_ALT_CAMERA): vol.All(cv.ensure_list, [cv.entity_id]), 

419 vol.Optional(CONF_ALIAS): cv.string, 

420 vol.Optional(CONF_URL): cv.url, 

421 vol.Optional(CONF_DEVICE_TRACKER): cv.entity_id, 

422 vol.Optional(CONF_PTZ_CAMERA): cv.entity_id, 

423 vol.Optional(CONF_PTZ_PRESET_DEFAULT, default=1): vol.Any(cv.positive_int, cv.string), 

424 vol.Optional(CONF_PTZ_DELAY, default=PTZ_DELAY_DEFAULT): int, 

425 vol.Optional(CONF_PTZ_METHOD, default=PTZ_METHOD_ONVIF): vol.In(PTZ_METHOD_VALUES), 

426 vol.Optional(CONF_SNAP_WAIT, default=SNAP_WAIT_DEFAULT): int, 

427}) 

428MEDIA_SCHEMA = vol.Schema({ 

429 vol.Optional(ATTR_MEDIA_CAMERA_ENTITY_ID): cv.entity_id, 

430 vol.Optional(ATTR_MEDIA_CAMERA_DELAY): int, 

431 vol.Optional(ATTR_MEDIA_CAMERA_PTZ_PRESET): vol.Any(cv.positive_int, cv.string), 

432 # URL fragments allowed 

433 vol.Optional(ATTR_MEDIA_CLIP_URL): vol.Any(cv.url, cv.string), 

434 vol.Optional(ATTR_MEDIA_SNAPSHOT_URL): vol.Any(cv.url, cv.string), 

435 vol.Optional(ATTR_MEDIA_SNAPSHOT_PATH): cv.path, 

436 vol.Optional(ATTR_JPEG_OPTS): dict, 

437 vol.Optional(ATTR_PNG_OPTS): dict, 

438}) 

439 

440 

441SCENARIO_SCHEMA = vol.All( 

442 _migrate_condition, 

443 cv.deprecated(key="delivery_selection"), 

444 vol.Schema({ 

445 vol.Optional(CONF_ALIAS): cv.string, 

446 vol.Optional(CONF_ENABLED, default=True): cv.boolean, 

447 vol.Optional(CONF_EXPOSE_STATE, default=True): cv.boolean, 

448 vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, 

449 vol.Optional(CONF_MEDIA): MEDIA_SCHEMA, 

450 vol.Optional(CONF_ACTION_GROUP_NAMES, default=[]): vol.All(cv.ensure_list, [cv.string]), 

451 vol.Optional("delivery_selection"): cv.string, 

452 vol.Optional(CONF_DELIVERY, default=dict): {cv.string: vol.Any(None, DELIVERY_CUSTOMIZE_SCHEMA)}, 

453 }), 

454) 

455 

456 

457def _mobile_action_uri(value: str) -> str: 

458 """Validate URI for mobile actions — accepts http/https and homeassistant:// deep links.""" 

459 if value and not (value.startswith(("http://", "https://", "homeassistant://"))): 

460 raise vol.Invalid(f"Invalid URI {value!r}: must start with http://, https://, or homeassistant://") 

461 return value 

462 

463 

464MOBILE_ACTION_CALL_SCHEMA = vol.All( 

465 cv.deprecated(key="action_category"), # deprecated v2.5.0 

466 vol.Schema( 

467 { 

468 vol.Optional(ATTR_ACTION): cv.string, 

469 vol.Optional(ATTR_TITLE): cv.string, 

470 vol.Optional("action_category"): cv.string, 

471 vol.Optional(ATTR_ACTION_URL): cv.url, 

472 vol.Optional(ATTR_ACTION_URL_TITLE): cv.string, 

473 }, 

474 extra=vol.ALLOW_EXTRA, 

475 ), 

476) 

477 

478MOBILE_ACTION_SCHEMA = vol.Schema( 

479 { 

480 vol.Exclusive(CONF_ACTION, CONF_ACTION_TEMPLATE): cv.string, 

481 vol.Exclusive(CONF_TITLE, CONF_TITLE_TEMPLATE): cv.string, 

482 vol.Optional(CONF_URI): _mobile_action_uri, 

483 vol.Optional(CONF_ICON): cv.string, 

484 }, 

485 extra=vol.ALLOW_EXTRA, 

486) 

487 

488 

489ARCHIVE_SCHEMA = vol.All( 

490 cv.deprecated(key=CONF_DEBUG), # deprecated v1.10.0 

491 vol.Schema({ 

492 vol.Optional(CONF_ARCHIVE_PATH, default=ARCHIVE_DIR): cv.path, 

493 vol.Optional(CONF_ENABLED, default=False): cv.boolean, 

494 vol.Optional(CONF_ARCHIVE_DAYS, default=3): cv.positive_int, 

495 vol.Optional(CONF_ARCHIVE_MQTT_TOPIC): cv.string, 

496 vol.Optional(CONF_ARCHIVE_MQTT_QOS, default=0): cv.positive_int, 

497 vol.Optional(CONF_ARCHIVE_MQTT_RETAIN, default=True): cv.boolean, 

498 vol.Optional(CONF_ARCHIVE_PURGE_INTERVAL, default=60): cv.positive_int, 

499 vol.Optional(CONF_ARCHIVE_EVENT_NAME, default="supernotification"): cv.string, 

500 vol.Optional(CONF_ARCHIVE_EVENT_SELECTION, default=OutcomeSelection.NONE): parse_event_policy, 

501 vol.Optional(CONF_ARCHIVE_DIAGNOSTICS, default=OutcomeSelection.ERROR): parse_event_policy, 

502 vol.Optional(CONF_DEBUG, default=False): cv.boolean, 

503 }), 

504) 

505 

506HOUSEKEEPING_SCHEMA = vol.Schema({ 

507 vol.Optional(CONF_HOUSEKEEPING_TIME, default="00:00:01"): cv.time, 

508 vol.Optional(CONF_MEDIA_STORAGE_DAYS, default=7): cv.positive_int, 

509}) 

510 

511# The 8 keys not yet configurable via ConfigFlow - defined under the top-level `supernotify:` 

512# YAML key (see CONFIG_SCHEMA/async_setup in __init__.py), never under the legacy notify platform. 

513# extra=ALLOW_EXTRA matches the old PLATFORM_SCHEMA-derived schema's inherited leniency (HA's 

514# base notify PLATFORM_SCHEMA itself uses ALLOW_EXTRA) - stray/unrecognized keys pass through 

515# rather than failing validation. 

516SUPERNOTIFY_YAML_SCHEMA: vol.Schema = vol.Schema( 

517 { 

518 vol.Optional(CONF_DELIVERY, default=dict): {cv.string: DELIVERY_SCHEMA}, 

519 vol.Optional(CONF_ACTION_GROUPS, default=dict): {cv.string: [MOBILE_ACTION_SCHEMA]}, 

520 vol.Optional(CONF_RECIPIENTS, default=list): vol.All(cv.ensure_list, [RECIPIENT_SCHEMA]), 

521 vol.Optional(CONF_LINKS, default=list): vol.All(cv.ensure_list, [LINK_SCHEMA]), 

522 vol.Optional(CONF_SCENARIOS, default=dict): vol.All( 

523 {cv.string: SCENARIO_SCHEMA}, 

524 validate_scenario_names, 

525 ), 

526 vol.Optional(CONF_TRANSPORTS, default=dict): {cv.string: TRANSPORT_SCHEMA}, 

527 vol.Optional(CONF_CAMERAS, default=list): vol.All(cv.ensure_list, [CAMERA_SCHEMA]), 

528 vol.Optional(CONF_SNOOZE, default=dict): SNOOZE_SCHEMA, 

529 vol.Optional(CONF_SCENARIO_CONTROL, default=dict): SCENARIO_CONTROL_SCHEMA, 

530 }, 

531 extra=vol.ALLOW_EXTRA, 

532) 

533 

534# The 8 ConfigEntry-owned keys only (entry.data/entry.options) - deliberately separate from 

535# SUPERNOTIFY_YAML_SCHEMA rather than folded into one combined schema: __init__.py's 

536# Defaults for every delivery, from the Delivery Control options page - each left out when not set, 

537# so deliveries keep their transport's own default 

538# REMOVE_EXTRA drops options no longer offered, such as the 2.10.0 beta's apple_drop_mp4 

539DELIVERY_CONTROL_SCHEMA = vol.Schema( 

540 { 

541 vol.Optional(CONF_DEFAULT_INCLUSION): vol.In(DEFAULT_INCLUSION_VALUES), 

542 vol.Optional(CONF_VOICE_OCCUPANCY): vol.In(OCCUPANCY_VALUES), 

543 }, 

544 extra=vol.REMOVE_EXTRA, 

545) 

546 

547# _entry_full_config validates entry.data/entry.options through this schema, then merges in the 

548# top-level YAML section's already-validated dict as-is. Re-validating that already-validated 

549# dict a second time (as a single combined schema would require) breaks - cv.template/ 

550# cv.CONDITIONS_SCHEMA coerce string templates into Template objects, and reject a Template 

551# object the second time round ("template value should be a string"). 

552CONFIG_ENTRY_SCHEMA = vol.Schema( 

553 { 

554 vol.Optional(CONF_DELIVERY_CONTROL, default=dict): DELIVERY_CONTROL_SCHEMA, 

555 vol.Optional(CONF_TEMPLATE_PATH, default=TEMPLATE_DIR): cv.path, 

556 vol.Optional(CONF_MEDIA_PATH, default=MEDIA_DIR): cv.path, 

557 vol.Optional(CONF_MEDIA_URL_PREFIX, default="/supernotify/media"): cv.string, 

558 vol.Optional(CONF_ARCHIVE, default={CONF_ENABLED: False}): ARCHIVE_SCHEMA, 

559 vol.Optional(CONF_HOUSEKEEPING, default={}): HOUSEKEEPING_SCHEMA, 

560 vol.Optional(CONF_DUPE_CHECK, default=dict): NOTIFICATION_DUPE_SCHEMA, 

561 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean, 

562 vol.Optional(CONF_RECIPIENTS_DISCOVERY, default=True): cv.boolean, 

563 }, 

564 extra=vol.ALLOW_EXTRA, 

565) 

566 

567# The union of CONFIG_ENTRY_SCHEMA and SUPERNOTIFY_YAML_SCHEMA - for validating a single, 

568# never-previously-validated raw config dict in one pass (e.g. hass_setup_lib.py's 

569# TestingContext). Do NOT use this in __init__.py's _entry_full_config - see CONFIG_ENTRY_SCHEMA. 

570FULL_CONFIG_SCHEMA = SUPERNOTIFY_YAML_SCHEMA.extend({ 

571 vol.Optional(CONF_DELIVERY_CONTROL, default=dict): DELIVERY_CONTROL_SCHEMA, 

572 vol.Optional(CONF_TEMPLATE_PATH, default=TEMPLATE_DIR): cv.path, 

573 vol.Optional(CONF_MEDIA_PATH, default=MEDIA_DIR): cv.path, 

574 vol.Optional(CONF_MEDIA_URL_PREFIX, default="/supernotify/media"): cv.string, 

575 vol.Optional(CONF_ARCHIVE, default={CONF_ENABLED: False}): ARCHIVE_SCHEMA, 

576 vol.Optional(CONF_HOUSEKEEPING, default={}): HOUSEKEEPING_SCHEMA, 

577 vol.Optional(CONF_DUPE_CHECK, default=dict): NOTIFICATION_DUPE_SCHEMA, 

578 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean, 

579 vol.Optional(CONF_RECIPIENTS_DISCOVERY, default=True): cv.boolean, 

580}) 

581 

582 

583# A delivery name, a list of names to restrict to, or a mapping of names to tuning 

584DELIVERY_FIELD_SCHEMA = vol.Any(cv.string, [cv.string], {cv.string: vol.Any(None, DELIVERY_CUSTOMIZE_SCHEMA)}) 

585 

586_ACTION_DATA_FIELDS_SCHEMA = vol.Schema( 

587 { 

588 vol.Optional(ATTR_DELIVERY): DELIVERY_FIELD_SCHEMA, 

589 vol.Optional(ATTR_PRIORITY): vol.Any(int, str, vol.In(list(PRIORITY_VALUES.keys()))), 

590 vol.Optional(ATTR_SCENARIOS_REQUIRE): vol.All(cv.ensure_list, [cv.string]), 

591 vol.Optional(ATTR_SCENARIOS_APPLY): vol.All(cv.ensure_list, [cv.string]), 

592 vol.Optional(ATTR_SCENARIOS_CONSTRAIN): vol.All(cv.ensure_list, [cv.string]), 

593 vol.Optional(ATTR_DELIVERY_SELECTION): vol.In(DELIVERY_SELECTION_VALUES), 

594 vol.Optional(ATTR_RECIPIENTS): vol.All(cv.ensure_list, [cv.entity_id]), 

595 vol.Optional(ATTR_MEDIA): MEDIA_SCHEMA, 

596 vol.Optional(ATTR_MESSAGE_HTML): cv.string, 

597 vol.Optional(ATTR_SPOKEN_MESSAGE): cv.string, 

598 vol.Optional(ATTR_ACTION_GROUPS, default=[]): vol.All(cv.ensure_list, [cv.string]), 

599 vol.Optional(ATTR_ACTIONS, default=[]): vol.All(cv.ensure_list, [MOBILE_ACTION_CALL_SCHEMA]), 

600 vol.Optional(ATTR_DEBUG, default=False): cv.boolean, 

601 vol.Optional(ATTR_FORCE_RESEND, default=False): cv.boolean, 

602 vol.Optional(ATTR_DATA): vol.Any(None, DATA_SCHEMA), 

603 vol.Optional(ATTR_TIMESTAMP): cv.string, 

604 }, 

605 extra=vol.ALLOW_EXTRA, # allow other data, e.g. the android/ios mobile push 

606) 

607 

608ACTION_DATA_FIELDS: frozenset[str] = frozenset(str(key) for key in _ACTION_DATA_FIELDS_SCHEMA.schema) 

609 

610ACTION_DATA_SCHEMA = vol.All( 

611 cv.deprecated(key=ATTR_RECIPIENTS), # deprecated v2.2.0 

612 _ACTION_DATA_FIELDS_SCHEMA, 

613) 

614 

615STRICT_ACTION_DATA_SCHEMA = vol.All( 

616 cv.deprecated(key=ATTR_RECIPIENTS), # deprecated v2.2.0 

617 _ACTION_DATA_FIELDS_SCHEMA.extend({}, extra=vol.REMOVE_EXTRA), 

618) 

619 

620# Schema for the supernotify.notify action - the same fields as ACTION_DATA_SCHEMA (normally 

621# buried in notify.supernotify's generic `data:` blob), but promoted to top-level fields so 

622# services.yaml can attach a selector to each one for a typed, discoverable UI in Developer 

623# Tools/the action picker, rather than one opaque object field. 

624NOTIFY_ACTION_SCHEMA = vol.All( 

625 cv.deprecated(key=ATTR_RECIPIENTS), # deprecated v2.2.0 

626 _ACTION_DATA_FIELDS_SCHEMA.extend({ 

627 vol.Required(CONF_MESSAGE): cv.string, 

628 vol.Optional(CONF_TITLE): cv.string, 

629 vol.Optional(CONF_TARGET): TARGET_SCHEMA, 

630 # pass-through data exempt from the legacy nested-data migration - see actions.py 

631 vol.Optional(ATTR_EXTRA_DATA): vol.Any(None, DATA_SCHEMA), 

632 # the target selector behind CONF_TARGET can only produce entity/device/area/floor/label 

633 # ids, so custom_target is a free-text escape hatch for targets it can't - e-mail 

634 # addresses, phone numbers, Slack ids etc. notify.py's action_notify merges 

635 # it back into target before Notification ever sees it 

636 vol.Optional(ATTR_CUSTOM_TARGET): vol.All(cv.ensure_list, [cv.string]), 

637 # the delivery dropdown can only give names, so this takes any form `delivery` does, and 

638 # actions.py's action_notify merges the two into `delivery` before Notification sees them 

639 vol.Optional(ATTR_DELIVERY_CONTROL): vol.Any(None, DELIVERY_FIELD_SCHEMA), 

640 # promoted out of media: for their own selectors - notify.py's action_notify 

641 # merges them back into media before Notification ever sees them 

642 vol.Optional(ATTR_MEDIA_CAMERA_ENTITY_ID): cv.entity_id, 

643 vol.Optional(ATTR_MEDIA_CLIP_URL): vol.Any(cv.url, cv.string), 

644 vol.Optional(ATTR_MEDIA_SNAPSHOT_URL): vol.Any(cv.url, cv.string), 

645 }), 

646)