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

1"""The Supernotify integration""" 

2 

3import datetime as dt 

4import re 

5from collections.abc import Callable 

6from enum import IntFlag, StrEnum, auto 

7from typing import Any 

8 

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 

34 

35from custom_components.supernotify import ARCHIVE_DIR, MEDIA_DIR, TEMPLATE_DIR 

36 

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) 

188 

189 

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 

199 

200 

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}") 

212 

213 

214class DeliveryOutcome(StrEnum): 

215 SUCCESS = auto() 

216 NO_DELIVERY = auto() 

217 PARTIAL_DELIVERY = auto() 

218 FALLBACK_DELIVERY = auto() 

219 ERROR = auto() 

220 DUPE = auto() 

221 

222 

223class EnvelopeOutcome(StrEnum): 

224 SUCCESS = auto() 

225 ERROR = auto() 

226 SKIPPED = auto() # envelope not generated 

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

228 

229 

230class SelectionRank(StrEnum): 

231 FIRST = "FIRST" 

232 ANY = "ANY" 

233 LAST = "LAST" 

234 

235 

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

237 

238 

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) 

245 

246 

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 

253 

254 

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) 

279 

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

281 

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}) 

299 

300 

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}) 

317 

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}) 

328 

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

330 

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}) 

365 

366 

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 

374 

375 

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) 

387 

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}) 

397 

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" 

425 

426 

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}) 

469 

470 

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) 

489 

490 

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 

496 

497 

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 

510 

511 

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 

516 

517 

518_MOBILE_ACTION_LEVELS = vol.In([ACTION_LEVEL_ACTION, ACTION_LEVEL_NOTIFICATION]) 

519 

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) 

535 

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) 

550 

551 

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) 

568 

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}) 

574 

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) 

598 

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) 

611 

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) 

634 

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) 

648 

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}) 

663 

664 

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)}) 

667 

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) 

692 

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

694 

695ACTION_DATA_SCHEMA = vol.All( 

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

697 _ACTION_DATA_FIELDS_SCHEMA, 

698) 

699 

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) 

704 

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) 

732 

733 

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