Coverage for custom_components/supernotify/target.py: 98%

337 statements  

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

1from __future__ import annotations 

2 

3import logging 

4import re 

5from dataclasses import dataclass 

6from typing import TYPE_CHECKING, Any, ClassVar 

7 

8import voluptuous as vol 

9from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN 

10from homeassistant.const import ( 

11 ATTR_AREA_ID, 

12 ATTR_DEVICE_ID, 

13 ATTR_ENTITY_ID, 

14 ATTR_FLOOR_ID, 

15 ATTR_LABEL_ID, 

16) 

17from homeassistant.core import valid_entity_id 

18from homeassistant.helpers.redact import partial_redact 

19 

20from .common import ensure_list 

21from .const import ( 

22 ATTR_EMAIL, 

23 ATTR_MOBILE_APP_ID, 

24 ATTR_PERSON_ID, 

25 ATTR_PHONE, 

26 RE_DEVICE_ID, 

27 TARGET_CATEGORY_VALUES, 

28) 

29from .schema import phone 

30 

31if TYPE_CHECKING: 

32 from collections.abc import Collection, Sequence 

33 

34 from .hass_api import HomeAssistantAPI 

35 from .model import SelectionRule 

36 

37 

38_LOGGER = logging.getLogger(__name__) 

39 

40# Avoid importing this from HA component to dodge heavy imports until lazyimport available 

41MOBILE_APP_DOMAIN = "mobile_app" 

42 

43 

44@dataclass(frozen=True) 

45class TargetEntityCategory: 

46 """Declares which entities a transport accepts for the `entity_id` target category. 

47 

48 Used in a `Transport.target_categories` list in place of a plain category name, since 

49 "any entity_id" is too broad - most entity-based transports only want entities of a 

50 particular domain (or domains), or ones registered by a particular platform/integration 

51 (or both). `domain`/`platform` may each be a single value or a list; `None` means 

52 "don't filter on this". 

53 """ 

54 

55 domain: str | list[str] | None = None 

56 platform: str | list[str] | None = None 

57 

58 def matches(self, entity_id: str, hass_api: HomeAssistantAPI) -> bool: 

59 if self.domain is not None: 

60 domains = [self.domain] if isinstance(self.domain, str) else self.domain 

61 if entity_id.split(".", 1)[0] not in domains: 

62 return False 

63 # only look the platform up, in the entity registry, when there's a platform to check against 

64 if self.platform is not None: 

65 platforms = [self.platform] if isinstance(self.platform, str) else self.platform 

66 if hass_api.platform_for_entity(entity_id) not in platforms: 

67 return False 

68 return True 

69 

70 

71class Target: 

72 # actual targets, that can positively identified with a validator 

73 DIRECT_CATEGORIES: ClassVar[list[str]] = [ATTR_ENTITY_ID, ATTR_DEVICE_ID, ATTR_EMAIL, ATTR_PHONE, ATTR_MOBILE_APP_ID] 

74 # references that lead to targets, that can positively identified with a validator 

75 AUTO_INDIRECT_CATEGORIES: ClassVar[list[str]] = [ATTR_PERSON_ID] 

76 # references that lead to targets, that can't be positively identified with a validator 

77 EXPLICIT_INDIRECT_CATEGORIES: ClassVar[list[str]] = [ATTR_AREA_ID, ATTR_FLOOR_ID, ATTR_LABEL_ID] 

78 INDIRECT_CATEGORIES = EXPLICIT_INDIRECT_CATEGORIES + AUTO_INDIRECT_CATEGORIES 

79 AUTO_CATEGORIES = DIRECT_CATEGORIES + AUTO_INDIRECT_CATEGORIES 

80 

81 CATEGORIES = DIRECT_CATEGORIES + INDIRECT_CATEGORIES 

82 

83 UNKNOWN_CUSTOM_CATEGORY = "_UNKNOWN_" 

84 

85 def __init__( 

86 self, 

87 target: str 

88 | list[str] 

89 | dict[str, str] 

90 | dict[str, Sequence[str]] 

91 | dict[str, list[str]] 

92 | dict[str, str | list[str]] 

93 | None = None, 

94 target_data: dict[str, Any] | None = None, 

95 target_specific_data: bool = False, 

96 ) -> None: 

97 self.target_data: dict[str, Any] | None = None 

98 self.target_specific_data: dict[tuple[str, str], dict[str, Any]] | None = None 

99 self.targets: dict[str, list[str]] = {} 

100 

101 matched: list[str] 

102 

103 if isinstance(target, str): 

104 target = [target] 

105 

106 if target is None: 

107 pass # empty constructor is valid case for target building 

108 elif isinstance(target, list): 

109 # simplified and legacy way of assuming list of entities that can be discriminated by validator 

110 targets = list(target) 

111 # "<category>:<value>" entries scope a custom target without needing the full 

112 # mapping form - only recognised here, in the flat list/scalar form, so the mapping 

113 # form (`target: {category: value}`) stays available verbatim for any value that 

114 # collides with this syntax (e.g. one already containing a colon). A prefix is always 

115 # a target *category* (e.g. `topic:`, `email:`), never a transport or delivery name 

116 # directly - those are dynamic and only addressable via the mapping form. 

117 unprefixed: list[str] = [] 

118 for t in targets: 

119 prefix, sep, rest = t.partition(":") if isinstance(t, str) else ("", "", "") 

120 if sep and rest and prefix in TARGET_CATEGORY_VALUES: 

121 self.targets.setdefault(prefix, []) 

122 if rest not in self.targets[prefix]: 

123 self.targets[prefix].append(rest) 

124 else: 

125 unprefixed.append(t) 

126 targets = unprefixed 

127 for category in self.AUTO_CATEGORIES: 

128 matched = self._filter_by_category(category, targets) 

129 if matched: 

130 self.targets.setdefault(category, []) 

131 self.targets[category].extend([t for t in matched if t not in self.targets[category]]) 

132 targets = [t for t in targets if t not in matched] 

133 if not targets: 

134 break 

135 if targets: 

136 self.targets[self.UNKNOWN_CUSTOM_CATEGORY] = targets 

137 

138 elif isinstance(target, dict): 

139 for category in target: 

140 targets = ensure_list(target[category]) 

141 if not targets: 

142 continue 

143 if category in self.AUTO_CATEGORIES: 

144 matched = self._filter_by_category(category, targets) 

145 if matched: 

146 self.targets.setdefault(category, []) 

147 self.targets[category].extend([t for t in matched if t not in self.targets[category]]) 

148 

149 elif category in self.CATEGORIES: 

150 # categories that can't be automatically detected, like label_id 

151 self.targets[category] = targets 

152 else: 

153 # custom categories 

154 self.targets[category] = targets 

155 else: 

156 _LOGGER.warning("SUPERNOTIFY Target created with no valid targets: %s", target) 

157 

158 if target_data and target_specific_data: 

159 self.target_specific_data = {} 

160 for category, targets in self.targets.items(): 

161 for t in targets: 

162 self.target_specific_data[category, t] = target_data 

163 if target_data and not target_specific_data: 

164 self.target_data = target_data 

165 

166 def _filter_by_category(self, category: str, candidates: list[str]) -> list[str]: 

167 matched: list[str] = [] 

168 validator = getattr(self, f"is_{category}", None) 

169 if validator is not None: 

170 for t in candidates: 

171 if t not in matched and validator(t): 

172 matched.append(t) 

173 else: 

174 _LOGGER.debug("SUPERNOTIFY Missing validator for selective target category %s", category) 

175 return matched 

176 

177 # Targets by category 

178 

179 @property 

180 def email(self) -> list[str]: 

181 return self.targets.get(ATTR_EMAIL, []) 

182 

183 @property 

184 def entity_ids(self) -> list[str]: 

185 return self.targets.get(ATTR_ENTITY_ID, []) 

186 

187 @property 

188 def person_ids(self) -> list[str]: 

189 return self.targets.get(ATTR_PERSON_ID, []) 

190 

191 @property 

192 def device_ids(self) -> list[str]: 

193 return self.targets.get(ATTR_DEVICE_ID, []) 

194 

195 @property 

196 def phone(self) -> list[str]: 

197 return self.targets.get(ATTR_PHONE, []) 

198 

199 @property 

200 def mobile_app_ids(self) -> list[str]: 

201 return self.targets.get(ATTR_MOBILE_APP_ID, []) 

202 

203 def domain_entity_ids(self, domain: str | None) -> list[str]: 

204 return [t for t in self.targets.get(ATTR_ENTITY_ID, []) if domain is not None and t and t.startswith(f"{domain}.")] 

205 

206 def custom_ids(self, category: str) -> list[str]: 

207 return self.targets.get(category, []) if category not in self.CATEGORIES else [] 

208 

209 @property 

210 def area_ids(self) -> list[str]: 

211 return self.targets.get(ATTR_AREA_ID, []) 

212 

213 @property 

214 def floor_ids(self) -> list[str]: 

215 return self.targets.get(ATTR_FLOOR_ID, []) 

216 

217 @property 

218 def label_ids(self) -> list[str]: 

219 return self.targets.get(ATTR_LABEL_ID, []) 

220 

221 # Selectors / validators 

222 

223 @classmethod 

224 def is_device_id(cls, target: str) -> bool: 

225 return re.fullmatch(RE_DEVICE_ID, target) is not None 

226 

227 @classmethod 

228 def is_entity_id(cls, target: str) -> bool: 

229 return valid_entity_id(target) and not target.startswith(("person.", "user.")) 

230 

231 @classmethod 

232 def is_person_id(cls, target: str) -> bool: 

233 """True for a real Person entity_id, or a Recipient's synthetic `user.<name>` id - used 

234 to identify a recipient with no Person record (see people.Recipient.entity_id). Both are 

235 `person_id`-category target values, resolved the same way downstream.""" 

236 return target.startswith(("person.", "user.")) and valid_entity_id(target) 

237 

238 @classmethod 

239 def is_phone(cls, target: str) -> bool: 

240 try: 

241 return phone(target) is not None 

242 except vol.Invalid: 

243 return False 

244 

245 @classmethod 

246 def is_mobile_app_id(cls, target: str) -> bool: 

247 return not valid_entity_id(target) and target.startswith(f"{MOBILE_APP_DOMAIN}_") 

248 

249 @classmethod 

250 def is_notify_entity(cls, target: str) -> bool: 

251 return valid_entity_id(target) and target.startswith(f"{NOTIFY_DOMAIN}.") 

252 

253 @classmethod 

254 def is_email(cls, target: str) -> bool: 

255 try: 

256 return vol.Email()(target) is not None # type: ignore[call-arg] # ty: ignore[missing-argument] 

257 except vol.Invalid: 

258 return False 

259 

260 def has_targets(self) -> bool: 

261 return any(targets for targets in self.targets.values()) 

262 

263 def has_resolved_target(self) -> bool: 

264 return any(targets for category, targets in self.targets.items() if category not in self.INDIRECT_CATEGORIES) 

265 

266 def has_unknown_targets(self) -> bool: 

267 return len(self.targets.get(self.UNKNOWN_CUSTOM_CATEGORY, [])) > 0 

268 

269 def for_category(self, category: str) -> list[str]: 

270 return self.targets.get(category, []) 

271 

272 def resolved_targets(self) -> list[str]: 

273 result: list[str] = [] 

274 for category, targets in self.targets.items(): 

275 if category not in self.INDIRECT_CATEGORIES: 

276 result.extend(targets) 

277 return result 

278 

279 def hash_resolved(self) -> int: 

280 targets = [] 

281 for category in self.targets: 

282 if category not in self.INDIRECT_CATEGORIES: 

283 targets.extend(self.targets[category]) 

284 return hash(tuple(targets)) 

285 

286 @property 

287 def direct_categories(self) -> list[str]: 

288 return self.DIRECT_CATEGORIES + [cat for cat in self.targets if cat not in self.CATEGORIES] 

289 

290 def direct(self) -> Target: 

291 t = Target( 

292 {cat: targets for cat, targets in self.targets.items() if cat in self.direct_categories}, 

293 target_data=self.target_data, 

294 ) 

295 if self.target_specific_data: 

296 t.target_specific_data = {k: v for k, v in self.target_specific_data.items() if k[0] in self.direct_categories} 

297 return t 

298 

299 def extend(self, category: str, targets: list[str] | str) -> None: 

300 targets = ensure_list(targets) 

301 self.targets.setdefault(category, []) 

302 self.targets[category].extend(t for t in targets if t not in self.targets[category]) 

303 

304 def remove(self, category: str, targets: list[str] | str) -> None: 

305 targets = ensure_list(targets) 

306 if category in self.targets: 

307 self.targets[category] = [t for t in self.targets[category] if t not in targets] 

308 

309 def safe_copy(self) -> Target: 

310 t = Target(dict(self.targets), target_data=dict(self.target_data) if self.target_data else None) 

311 t.target_specific_data = dict(self.target_specific_data) if self.target_specific_data else None 

312 return t 

313 

314 def resolve_selectors(self, hass_api: HomeAssistantAPI) -> Target: 

315 """Replace `area_id`/`floor_id`/`label_id` targets with the entities they reference 

316 

317 Resolution goes through the same core helper as a Home Assistant entity action, so 

318 groups are expanded and an entity inherits the area of its device. An entity in more 

319 than one of them - the kitchen, the first floor and the `voice` label - is kept once, 

320 and data attached to a selector is inherited by each of its entities, the same way as 

321 for a group member. Transports therefore never see a selector, and only the exception 

322 of an action that genuinely knows about areas needs `extra_data`. 

323 

324 Returns this target untouched when there is no selector to resolve. 

325 """ 

326 if not any(self.targets.get(category) for category in self.EXPLICIT_INDIRECT_CATEGORIES): 

327 return self 

328 kwarg: dict[str, str] = {ATTR_AREA_ID: "area_ids", ATTR_FLOOR_ID: "floor_ids", ATTR_LABEL_ID: "label_ids"} 

329 targets: dict[str, list[str]] = { 

330 category: list(values) 

331 for category, values in self.targets.items() 

332 if category not in self.EXPLICIT_INDIRECT_CATEGORIES 

333 } 

334 entity_ids: list[str] = targets.setdefault(ATTR_ENTITY_ID, []) 

335 inherited: dict[tuple[str, str], dict[str, Any]] = {} 

336 unknown: list[str] = [] 

337 for category in self.EXPLICIT_INDIRECT_CATEGORIES: 

338 for value in self.targets.get(category, []): 

339 # one selector at a time, so data attached to it follows its own entities 

340 resolution = hass_api.resolve_target_selectors(**{kwarg[category]: [value]}) 

341 if resolution.has_missing(): 

342 unknown.append(f"{category}:{value}") 

343 data: dict[str, Any] | None = (self.target_specific_data or {}).get((category, value)) 

344 for entity_id in resolution.entity_ids: 

345 if entity_id not in entity_ids: 

346 entity_ids.append(entity_id) 

347 if data and (ATTR_ENTITY_ID, entity_id) not in inherited: 

348 inherited[(ATTR_ENTITY_ID, entity_id)] = data 

349 if unknown: 

350 # a typo in an area or label would otherwise silently resolve to nothing 

351 _LOGGER.warning("SUPERNOTIFY Unknown target selectors %s", ", ".join(unknown)) 

352 resolved = Target(targets, target_data=self.target_data) 

353 if inherited or self.target_specific_data: 

354 # data attached to the entity itself wins over data inherited from a selector 

355 resolved.target_specific_data = inherited | { 

356 key: data 

357 for key, data in (self.target_specific_data or {}).items() 

358 if key[0] not in self.EXPLICIT_INDIRECT_CATEGORIES 

359 } 

360 return resolved 

361 

362 def select( 

363 self, 

364 categories: Sequence[str | TargetEntityCategory], 

365 own_names: Collection[str], 

366 hass_api: HomeAssistantAPI, 

367 target_selector: SelectionRule | None = None, 

368 ) -> Target: 

369 """Narrow this target to what a delivery can use, leaving this one untouched 

370 

371 `categories` are the delivery's declared target categories, and `own_names` its own 

372 name and its transport's. A target category named after either is always destined 

373 for that delivery. The two serve different purposes and both stay available: 

374 - the TRANSPORT name (`sms:value`) reaches every delivery of that transport, so 

375 scenario/time/occupancy selection logic can still decide which one actually 

376 fires - the same as it would for a plain, auto-matched value 

377 - a specific DELIVERY name (`shortcode_sms:value`) pins the target to just that 

378 one delivery, for when two deliveries of the same transport must stay distinct 

379 (e.g. `email` vs `html_email`) 

380 

381 `person_id`s are always kept, whatever the delivery declares, since they aren't 

382 delivered to but are the link back to the recipients a delivery reaches (see 

383 `Notification.generate_targets()`, which narrows them to those actually in each 

384 envelope). They are kept out of the `target_selector` too, as it's for choosing 

385 between values a transport can address. 

386 """ 

387 if any(self.targets.get(category) for category in self.EXPLICIT_INDIRECT_CATEGORIES): 

388 # area/floor/label always become entities first, so everything downstream - the 

389 # category checks, `target_select`, the transports - only ever sees entity_ids 

390 return self.resolve_selectors(hass_api).select(categories, own_names, hass_api, target_selector) 

391 

392 plain_categories = {c for c in categories if isinstance(c, str)} 

393 entity_selectors = [c for c in categories if isinstance(c, TargetEntityCategory)] 

394 # HA groups (`group.*` helpers and platform groups such as media player groups) are 

395 # expanded into their members before the category and target_select checks, so a 

396 # transport that can't address a group itself still reaches its members 

397 expansions: dict[tuple[str, str], list[str]] = {} 

398 groups: set[tuple[str, str]] = set() 

399 

400 def accepted(category: str, t: str, restricted: bool) -> bool: 

401 if ( 

402 restricted 

403 and entity_selectors 

404 and category == ATTR_ENTITY_ID 

405 and not any(sel.matches(t, hass_api) for sel in entity_selectors) 

406 ): 

407 return False 

408 return target_selector is None or target_selector.match(t) 

409 

410 def selected(category: str, targets: list[str]) -> list[str]: 

411 if category == ATTR_PERSON_ID: 

412 return targets 

413 restricted = category not in own_names 

414 if ( 

415 restricted 

416 and not (entity_selectors and category == ATTR_ENTITY_ID) 

417 and plain_categories 

418 and category not in plain_categories 

419 ): 

420 # this delivery declares fixed categories (from its transport, its own 

421 # config, or both) - anything outside that set is rejected 

422 return [] 

423 # else: this delivery declares no categories at all (e.g. `generic` with no 

424 # config) - nothing to restrict against 

425 chosen: list[str] = [] 

426 for t in targets: 

427 members = hass_api.group_members(t) if category == ATTR_ENTITY_ID else None 

428 if members is None: 

429 matched = [t] if accepted(category, t, restricted) else [] 

430 else: 

431 groups.add((category, t)) 

432 matched = [m for m in members if accepted(category, m, restricted)] 

433 if not matched and accepted(category, t, restricted): 

434 # group members unusable but the group id itself accepted, e.g. alexa_devices 

435 matched = [t] 

436 expansions[(category, t)] = matched 

437 chosen.extend(m for m in matched if m not in chosen) 

438 return chosen 

439 

440 filtered_target = Target({k: selected(k, v) for k, v in self.targets.items()}, target_data=self.target_data) 

441 if self.target_specific_data: 

442 # data inherited from a group first, so data explicitly attached to a member always wins 

443 specific_data: dict[tuple[str, str], dict[str, Any]] = {} 

444 for (c, t), data in self.target_specific_data.items(): 

445 if (c, t) in groups: 

446 for m in expansions.get((c, t), []): 

447 specific_data[(c, m)] = data 

448 for (c, t), data in self.target_specific_data.items(): 

449 if (c, t) not in groups and c in filtered_target.targets and t in filtered_target.targets[c]: 

450 specific_data[(c, t)] = data 

451 filtered_target.target_specific_data = specific_data 

452 return filtered_target 

453 

454 def split_by_target_data(self) -> list[Target]: 

455 if not self.target_specific_data: 

456 result = self.safe_copy() 

457 result.target_specific_data = None 

458 return [result] 

459 results: list[Target] = [] 

460 default: Target = self.safe_copy() 

461 default.target_specific_data = None 

462 last_found: dict[str, Any] | None = None 

463 collected: dict[str, list[str]] = {} 

464 for (category, target), data in self.target_specific_data.items(): 

465 if last_found is None: 

466 last_found = data 

467 collected = {category: [target]} 

468 elif data != last_found and last_found is not None: 

469 new_target: Target = Target(collected, target_data=last_found) 

470 results.append(new_target) 

471 default -= new_target 

472 last_found = data 

473 collected = {category: [target]} 

474 else: 

475 collected.setdefault(category, []) 

476 collected[category].append(target) 

477 new_target = Target(collected, target_data=last_found) 

478 results.append(new_target) 

479 default -= new_target 

480 if default.has_resolved_target(): 

481 results.append(default) 

482 return results 

483 

484 def __len__(self) -> int: 

485 """How many targets, whether direct or indirect""" 

486 return sum(len(targets) for targets in self.targets.values()) 

487 

488 def __add__(self, other: Target) -> Target: 

489 """Create a new target by adding another to this one""" 

490 new = Target() 

491 categories = set(list(self.targets.keys()) + list(other.targets.keys())) 

492 for category in categories: 

493 new.targets[category] = list(self.targets.get(category, [])) 

494 new.targets[category].extend(t for t in other.targets.get(category, []) if t not in new.targets[category]) 

495 

496 new.target_data = dict(self.target_data) if self.target_data else None 

497 if other.target_data: 

498 if new.target_data is None: 

499 new.target_data = dict(other.target_data) 

500 else: 

501 new.target_data.update(other.target_data) 

502 new.target_specific_data = dict(self.target_specific_data) if self.target_specific_data else None 

503 if other.target_specific_data: 

504 if new.target_specific_data is None: 

505 new.target_specific_data = dict(other.target_specific_data) 

506 else: 

507 new.target_specific_data.update(other.target_specific_data) 

508 return new 

509 

510 def __sub__(self, other: Target) -> Target: 

511 """Create a new target by removing another from this one, ignoring target_data""" 

512 new = Target() 

513 new.target_data = self.target_data 

514 if self.target_specific_data: 

515 new.target_specific_data = { 

516 k: v for k, v in self.target_specific_data.items() if k[1] not in other.targets.get(k[0], ()) 

517 } 

518 categories = set(list(self.targets.keys()) + list(other.targets.keys())) 

519 for category in categories: 

520 new.targets[category] = [] 

521 new.targets[category].extend(t for t in self.targets.get(category, []) if t not in other.targets.get(category, [])) 

522 

523 return new 

524 

525 def __eq__(self, other: object) -> bool: 

526 """Compare two targets""" 

527 if other is self: 

528 return True 

529 if other is None: 

530 return False 

531 if not isinstance(other, Target): 

532 return NotImplemented 

533 if self.target_data != other.target_data: 

534 return False 

535 if self.target_specific_data != other.target_specific_data: 

536 return False 

537 return all(self.targets.get(category, []) == other.targets.get(category, []) for category in self.CATEGORIES) 

538 

539 def as_dict(self, *, redact: bool = False, **_kwargs: Any) -> dict[str, list[str]]: 

540 result = {k: v for k, v in self.targets.items() if v} 

541 if redact: 

542 if ATTR_EMAIL in result: 

543 result[ATTR_EMAIL] = [partial_redact(v, unmasked_prefix=2, unmasked_suffix=1) for v in result[ATTR_EMAIL]] 

544 if ATTR_PHONE in result: 

545 result[ATTR_PHONE] = [partial_redact(v, unmasked_prefix=2, unmasked_suffix=1) for v in result[ATTR_PHONE]] 

546 return result