Coverage for custom_components/supernotify/delivery.py: 99%

292 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-08 17:42 +0000

1from __future__ import annotations 

2 

3import logging 

4from enum import StrEnum, auto 

5from typing import TYPE_CHECKING, Any 

6 

7from homeassistant.const import ( 

8 ATTR_DEVICE_ID, 

9 ATTR_FRIENDLY_NAME, 

10 ATTR_NAME, 

11 CONF_ACTION, 

12 CONF_ALIAS, 

13 CONF_CONDITIONS, 

14 CONF_DEBUG, 

15 CONF_ENABLED, 

16 CONF_NAME, 

17 CONF_OPTIONS, 

18 CONF_TARGET, 

19) 

20from homeassistant.core import callback 

21 

22from custom_components.supernotify.target import Target, TargetEntityCategory 

23 

24from .common import ensure_list 

25from .const import ( 

26 ATTR_ENABLED, 

27 ATTR_LAST_OUTCOME, 

28 ATTR_LAST_SENT_AT, 

29 ATTR_LAST_SKIP_REASON, 

30 ATTR_MOBILE_APP_ID, 

31 ATTR_TRANSPORT_ENABLED, 

32 CONF_DATA, 

33 CONF_DEFAULT_INCLUSION, 

34 CONF_DELIVERY_DEFAULTS, 

35 CONF_FALLBACK, 

36 CONF_INCLUSION, 

37 CONF_LOAD, 

38 CONF_MESSAGE, 

39 CONF_NOTIFY_ENTITY, 

40 CONF_OCCUPANCY, 

41 CONF_TARGET_REQUIRED, 

42 CONF_TARGET_USAGE, 

43 CONF_TEMPLATE, 

44 CONF_TITLE, 

45 CONF_TRANSPORT, 

46 CONF_VOICE_OCCUPANCY, 

47 INCLUSION_DEFAULT, 

48 INCLUSION_EXPLICIT, 

49 INCLUSION_FALLBACK, 

50 INCLUSION_FALLBACK_ON_ERROR, 

51 RESERVED_DELIVERY_NAMES, 

52) 

53from .model import ConditionVariables, DeliveryConfig, SelectionRule, TransportFeature 

54from .options import ( 

55 OPTION_DATA_KEYS_EXCLUDE_RE, 

56 OPTION_DATA_KEYS_INCLUDE_RE, 

57 OPTION_DATA_KEYS_SELECT, 

58 OPTION_DEVICE_AREA_SELECT, 

59 OPTION_DEVICE_DISCOVERY, 

60 OPTION_DEVICE_DOMAIN, 

61 OPTION_DEVICE_LABEL_SELECT, 

62 OPTION_DEVICE_MANUFACTURER_SELECT, 

63 OPTION_DEVICE_MODEL_SELECT, 

64 OPTION_DEVICE_OS_SELECT, 

65 OPTION_TARGET_CATEGORIES, 

66 OPTION_TARGET_INCLUDE_RE, 

67 OPTION_TARGET_SELECT, 

68 SELECT_EXCLUDE, 

69 SELECT_INCLUDE, 

70) 

71from .static_config import TRANSPORT_NAMES 

72 

73if TYPE_CHECKING: 

74 import datetime as dt 

75 

76 from homeassistant.helpers.typing import ConfigType 

77 

78 from custom_components.supernotify.hass_api import TrackedDeviceDetails 

79 from custom_components.supernotify.transport import Transport 

80 

81 from .binary_sensor import SupernotifyLegacyBinarySensor 

82 from .context import Context 

83 from .schema import ConditionsFunc 

84 

85_LOGGER = logging.getLogger(__name__) 

86 

87 

88class DeliveryProvenance(StrEnum): 

89 DEFAULT_STANDARD = auto() 

90 EXTRA_STANDARD = auto() 

91 CONFIG = auto() 

92 

93 

94class Delivery(DeliveryConfig): 

95 def __init__( 

96 self, name: str, conf: ConfigType, transport: Transport, provenance: DeliveryProvenance = DeliveryProvenance.CONFIG 

97 ) -> None: 

98 conf = conf or {} 

99 self.name: str = name 

100 self.provenance: DeliveryProvenance = provenance 

101 self.transport: Transport = transport 

102 self._raw_conf: ConfigType = conf 

103 # whether this delivery's own config explicitly declared `inclusion:` (or its 

104 # deprecated `selection:` alias, already normalised to `inclusion` by schema.py's 

105 # cv.deprecated() before this runs) - as opposed to merely inheriting the transport's 

106 # own bare default. A deliberate `inclusion: default` on a delivery someone configured 

107 # themselves is trusted at face value for implicit selection (see 

108 # DeliveryRegistry.implicit_deliveries), unlike a standard delivery's inherited default. 

109 self.explicit_inclusion: bool = CONF_INCLUSION in conf 

110 transport_defaults: DeliveryConfig = self.transport.delivery_defaults 

111 super().__init__(conf, delivery_defaults=transport_defaults) 

112 # deprecated option aliases must be rewritten before anything below reads self.options 

113 self.migrate_config() 

114 if isinstance(self.target, Target): 

115 # a value set directly on this delivery's own `target:` is exclusively scoped to 

116 # it - unlike a blended notification-level target list - so it's safe to claim an 

117 # unqualified value (no shape a validator recognises) for this transport. (The 

118 # isinstance check, not just a None check, is deliberate: a test double `Mock()` 

119 # transport can leave `self.target` as an auto-mocked attribute rather than None.) 

120 self.target = self.reclassify_unqualified_target(self.target) 

121 # as configured, which enabled can be overridden from at runtime by the delivery switch 

122 self.config_enabled: bool = conf.get(CONF_ENABLED, self.transport.config_enabled) 

123 self.enabled: bool = self.config_enabled 

124 # deliveries to try, in order, if this one fails - see Notification._fall_back() 

125 self.fallback: list[str] = list(conf.get(CONF_FALLBACK) or []) 

126 self.notify_entity: str | None = conf.get(CONF_NOTIFY_ENTITY) 

127 # set once its notify entity is made, see exposed_notify_entities() in notify.py 

128 self.notify_entity_id: str | None = None 

129 # set by the registry when another delivery lists this one in its `fallback:` 

130 self.is_fallback: bool = False 

131 self.conditions: ConditionsFunc | None = None 

132 self.transport_data: dict[str, Any] = {} 

133 # how the latest live notification went for this delivery - see record_outcome() 

134 self.last_sent_at: dt.datetime | None = None 

135 self.last_outcome: str | None = None 

136 self.last_skip_reason: str | None = None 

137 if self.options.get(OPTION_TARGET_SELECT): 

138 self.target_selector: SelectionRule | None = SelectionRule(self.options.get(OPTION_TARGET_SELECT)) 

139 else: 

140 self.target_selector = None 

141 

142 async def initialize(self, context: Context) -> bool: 

143 self.check_lost_implicit_inclusion(context) 

144 errors = await self.validate_config(context) 

145 self.discover_devices(context) 

146 self.transport_data = self.transport.setup_delivery_options(self.options, self.name) 

147 return errors == 0 

148 

149 def migrate_config(self) -> None: 

150 """Rewrite deprecated option aliases in place. Called from `__init__`, before 

151 

152 anything else reads `self.options` - unlike the checks in `validate_config()` and 

153 `check_lost_implicit_inclusion()`, this never needs `Context`, so there's no reason 

154 to defer it to `initialize()` and risk a constructed-but-not-initialized `Delivery` 

155 (as in many unit tests) seeing the pre-migration option name. 

156 """ 

157 # v1.9.0 

158 if ( 

159 OPTION_DATA_KEYS_INCLUDE_RE in self.options or OPTION_DATA_KEYS_EXCLUDE_RE in self.options 

160 ) and not self.options.get(OPTION_DATA_KEYS_SELECT): 

161 _LOGGER.warning( 

162 "SUPERNOTIFY Deprecated use of data_keys_include_re/data_keys_exclude_re options - use data_keys_select" 

163 ) 

164 self.options[OPTION_DATA_KEYS_SELECT] = { 

165 SELECT_INCLUDE: self.options.get(OPTION_DATA_KEYS_INCLUDE_RE), 

166 SELECT_EXCLUDE: self.options.get(OPTION_DATA_KEYS_EXCLUDE_RE), 

167 } 

168 # v1.9.0 

169 if OPTION_TARGET_INCLUDE_RE in self.options and not self.options.get(OPTION_TARGET_SELECT): 

170 _LOGGER.warning("SUPERNOTIFY Deprecated use of target_include_re option - use target_select") 

171 self.options[OPTION_TARGET_SELECT] = {SELECT_INCLUDE: self.options.get(OPTION_TARGET_INCLUDE_RE)} 

172 

173 def check_lost_implicit_inclusion(self, context: Context) -> None: 

174 """v2.11.0: flag a behaviour change that can't be silently auto-upgraded, unlike 

175 

176 `migrate_config()`'s option aliases - we can't know what inclusion the user actually 

177 wants, so this only raises a repair issue for them to act on. Needs `Context` 

178 (`delivery_registry.default_inclusion`, `hass_api.raise_issue`), so - like 

179 `validate_config()` - it's deferred to `initialize()` rather than run from `__init__`, 

180 to avoid raising issues for a `Delivery` that's constructed but never initialized (as 

181 in many unit tests). 

182 """ 

183 if ( 

184 self.provenance == DeliveryProvenance.CONFIG 

185 and CONF_INCLUSION not in self._raw_conf 

186 and INCLUSION_DEFAULT not in self.inclusion 

187 and context.delivery_registry.default_inclusion is None 

188 ): 

189 _LOGGER.info( 

190 "SUPERNOTIFY Delivery %s has no explicit inclusion, but transport %s no longer defaults to " 

191 "'default' - it will not fire implicitly", 

192 self.name, 

193 self.transport.name, 

194 ) 

195 context.hass_api.raise_issue( 

196 f"delivery_{self.name}_lost_implicit_inclusion", 

197 issue_key="delivery_lost_implicit_inclusion", 

198 issue_map={"delivery": self.name, "transport": self.transport.name}, 

199 learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/deliveries/", 

200 ) 

201 

202 async def validate_config(self, context: Context) -> int: 

203 """Genuine per-delivery validation - as opposed to the version-history migration 

204 

205 concerns in `migrate_config()` and `check_lost_implicit_inclusion()`. Each failure is 

206 raised as a repair issue and counted, so `DeliveryRegistry` can drop this delivery 

207 rather than run it in a state nobody asked for. 

208 """ 

209 errors = 0 

210 if self.name in TRANSPORT_NAMES and self.transport.name != self.name: 

211 _LOGGER.warning( 

212 "SUPERNOTIFY Delivery %s is a reserved name for the standard delivery of %s transport", self.name, self.name 

213 ) 

214 context.hass_api.raise_issue( 

215 f"delivery_{self.name}_reserved_name", 

216 issue_key="delivery_reserved_name", 

217 issue_map={"delivery": self.name}, 

218 learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/deliveries/", 

219 ) 

220 if self.name in RESERVED_DELIVERY_NAMES: 

221 _LOGGER.warning("SUPERNOTIFY Delivery uses reserved word %s", self.name) 

222 context.hass_api.raise_issue( 

223 f"delivery_{self.name}_reserved_name", 

224 issue_key="delivery_reserved_name", 

225 issue_map={"delivery": self.name}, 

226 learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/deliveries/", 

227 ) 

228 errors += 1 

229 if not self.transport.validate_action(self.action): 

230 _LOGGER.warning("SUPERNOTIFY Invalid action definition for delivery %s (%s)", self.name, self.action) 

231 context.hass_api.raise_issue( 

232 f"delivery_{self.name}_invalid_action", 

233 issue_key="delivery_invalid_action", 

234 issue_map={"delivery": self.name, "action": self.action or ""}, 

235 learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/deliveries/", 

236 ) 

237 errors += 1 

238 

239 if self.conditions_config: 

240 try: 

241 self.conditions = await context.hass_api.build_conditions( 

242 self.conditions_config, validate=True, strict=True, name=self.name 

243 ) 

244 passed = True 

245 exception = "" 

246 except Exception as e: 

247 passed = False 

248 exception = str(e) 

249 if not passed: 

250 _LOGGER.warning("SUPERNOTIFY Invalid delivery conditions for %s: %s", self.name, self.conditions_config) 

251 context.hass_api.raise_issue( 

252 f"delivery_{self.name}_invalid_condition", 

253 issue_key="delivery_invalid_condition", 

254 issue_map={"delivery": self.name, "condition": str(self.conditions_config), "exception": exception}, 

255 learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/deliveries/", 

256 ) 

257 errors += 1 

258 return errors 

259 

260 def discover_devices(self, context: Context) -> None: 

261 if self.options.get(OPTION_DEVICE_DISCOVERY, False): 

262 for domain in self.options.get(OPTION_DEVICE_DOMAIN, []): 

263 discovered: int = 0 

264 added: int = 0 

265 for d in context.hass_api.discover_devices( 

266 domain, 

267 device_model_select=SelectionRule(self.options.get(OPTION_DEVICE_MODEL_SELECT)), 

268 device_manufacturer_select=SelectionRule(self.options.get(OPTION_DEVICE_MANUFACTURER_SELECT)), 

269 device_os_select=SelectionRule(self.options.get(OPTION_DEVICE_OS_SELECT)), 

270 device_area_select=SelectionRule(self.options.get(OPTION_DEVICE_AREA_SELECT)), 

271 device_label_select=SelectionRule(self.options.get(OPTION_DEVICE_LABEL_SELECT)), 

272 ): 

273 discovered += 1 

274 if self.target is None: 

275 self.target = Target() 

276 if domain == "mobile_app": 

277 mobile_app: TrackedDeviceDetails | None = context.hass_api.mobile_app_by_device_id(d.device_id) 

278 if mobile_app and mobile_app.action: 

279 mobile_app_id = mobile_app.mobile_app_id if mobile_app else None 

280 if mobile_app_id and mobile_app_id not in self.target.mobile_app_ids: 

281 _LOGGER.debug( 

282 f"SUPERNOTIFY Found mobile {d.model} device {d.device_name} for {domain}, id {d.device_id}" 

283 ) 

284 self.target.extend(ATTR_MOBILE_APP_ID, mobile_app_id) 

285 added += 1 

286 else: 

287 _LOGGER.debug(f"SUPERNOTIFY Skipped mobile without notify entity {d.device_name}, id {d.device_id}") 

288 else: 

289 if d.device_id not in self.target.device_ids: 

290 _LOGGER.debug( 

291 "SUPERNOTIFY Found %s device %s for %s, id %s", d.model, d.device_name, domain, d.device_id 

292 ) 

293 self.target.extend(ATTR_DEVICE_ID, d.device_id) 

294 added += 1 

295 

296 _LOGGER.info(f"SUPERNOTIFY {self.name} Device discovery for {domain} found {discovered} devices, added {added}") 

297 

298 @property 

299 def target_categories(self) -> list[str | TargetEntityCategory]: 

300 """The target categories this delivery accepts - the query point for "what does this 

301 

302 delivery support", so callers never need to look at `Transport` and `OPTION_TARGET_ 

303 CATEGORIES` separately. For every transport other than `generic`, this is direct 

304 delegation to `self.transport.target_categories` (a delivery rarely needs to widen 

305 what its transport understands). `generic` is the bring-your-own-categories 

306 transport - it declares nothing itself, so a delivery's own `OPTION_TARGET_CATEGORIES` 

307 (e.g. a made-up "slack_channel" category) is what actually defines its categories. 

308 

309 The configured list is listed first, so it takes precedence as the reclassification 

310 fallback in `reclassify_unqualified_target()` when both are present. 

311 """ 

312 configured = ensure_list(self.options.get(OPTION_TARGET_CATEGORIES)) 

313 return [*configured, *self.transport.target_categories] 

314 

315 def reclassify_unqualified_target(self, target: Target) -> Target: 

316 """Reclassify this delivery's uncategorised target values into its primary target 

317 

318 category, since a value with no distinguishing shape (e.g. an MQTT topic, a Discord 

319 channel ID, or a made-up category for `generic`, like a Slack channel) would 

320 otherwise never survive `select_targets()` on its own. Left alone if this delivery's 

321 `target_categories` explicitly lists the uncategorised bucket itself - that's a 

322 deliberate choice to accept unqualified values exactly as they are - or if it has no 

323 plain-string category to fall back to at all. 

324 

325 Only safe to call on a `Target` that is exclusively scoped to this one delivery - its 

326 own configured `target:`, or a per-delivery override - never on a blended, 

327 notification-level target list. There, an unqualified value must stay unclaimed 

328 rather than being guessed at: it could belong to a different delivery entirely, and 

329 claiming it here would leak it away from wherever it actually belongs. 

330 """ 

331 unqualified = target.targets.get(Target.UNKNOWN_CUSTOM_CATEGORY) 

332 if not unqualified: 

333 return target 

334 declared = self.target_categories 

335 if Target.UNKNOWN_CUSTOM_CATEGORY in declared: 

336 return target 

337 primary = next((c for c in declared if isinstance(c, str)), None) 

338 if primary is None: 

339 # this target is exclusively scoped to this delivery (the safety precondition 

340 # above), so if there's genuinely nowhere for it to go, it's not a value meant 

341 # for a different delivery - it's just unmappable, and would otherwise be 

342 # dropped with no visible explanation 

343 _LOGGER.warning( 

344 "SUPERNOTIFY Delivery %s (%s) has no target category to accept unqualified target(s) %s - " 

345 "dropping. Known categories for this delivery: %s", 

346 self.name, 

347 self.transport.name, 

348 unqualified, 

349 [c if isinstance(c, str) else "entity_id" for c in declared] or "none", 

350 ) 

351 return target 

352 result = target.safe_copy() 

353 result.targets.pop(Target.UNKNOWN_CUSTOM_CATEGORY, None) 

354 result.extend(primary, unqualified) 

355 return result 

356 

357 def select_targets(self, target: Target) -> Target: 

358 return target.select( 

359 self.target_categories, (self.name, self.transport.name), self.transport.hass_api, self.target_selector 

360 ) 

361 

362 def evaluate_conditions(self, condition_variables: ConditionVariables) -> bool | None: 

363 if not self.enabled: 

364 return False 

365 if self.conditions is None: 

366 return True 

367 # TODO: reconsider hass_api injection 

368 return self.transport.hass_api.evaluate_conditions(self.conditions, condition_variables) 

369 

370 def option(self, option_name: str, default: str | bool) -> str | bool: 

371 """Get an option value from delivery config or transport default options""" 

372 opt: str | bool | None = None 

373 if option_name in self.options: 

374 opt = self.options[option_name] 

375 if opt is None: 

376 _LOGGER.debug( 

377 "SUPERNOTIFY No default in delivery %s for option %s, setting to default %s", self.name, option_name, default 

378 ) 

379 opt = self.options[option_name] = default 

380 return opt 

381 

382 def option_bool(self, option_name: str, default: bool = False) -> bool: 

383 return bool(self.option(option_name, default=default)) 

384 

385 def option_str(self, option_name: str) -> str: 

386 return str(self.option(option_name, default="")) 

387 

388 def as_dict(self, **_kwargs: Any) -> dict[str, Any]: 

389 base = super().as_dict() 

390 base.update({ 

391 CONF_NAME: self.name, 

392 CONF_ALIAS: self.alias, 

393 CONF_TRANSPORT: self.transport.name, 

394 CONF_TEMPLATE: self.template, 

395 CONF_MESSAGE: self.message, 

396 CONF_TITLE: self.title, 

397 CONF_ENABLED: self.enabled, 

398 CONF_OCCUPANCY: self.occupancy, 

399 CONF_CONDITIONS: self.conditions, 

400 }) 

401 return base 

402 

403 def record_outcome(self, outcome: str, at: dt.datetime, sent: bool = False, skip_reason: str | None = None) -> None: 

404 """Keep how the latest live notification went for this delivery: `outcome` is success, 

405 error or skipped, `sent` whether anything actually went out (so `last_sent_at` moves on), 

406 and `skip_reason` why it was skipped or suppressed. Shown on the delivery switch.""" 

407 self.last_outcome = outcome 

408 self.last_skip_reason = skip_reason 

409 if sent: 

410 self.last_sent_at = at 

411 

412 def attributes(self) -> dict[str, Any]: 

413 """For exposure as entity state""" 

414 attrs: dict[str, Any] = { 

415 ATTR_NAME: self.name, 

416 ATTR_ENABLED: self.enabled, 

417 CONF_TRANSPORT: self.transport.name, 

418 # a delivery is only used while its transport is enabled too 

419 ATTR_TRANSPORT_ENABLED: self.transport.enabled, 

420 CONF_ACTION: self.action, 

421 CONF_OPTIONS: self.options, 

422 CONF_INCLUSION: self.inclusion, 

423 CONF_TARGET: self.target, 

424 CONF_TARGET_REQUIRED: self.target_required, 

425 CONF_TARGET_USAGE: self.target_usage, 

426 CONF_DATA: self.data, 

427 CONF_DEBUG: self.debug, 

428 } 

429 if self.fallback: 

430 # so a dashboard can show which deliveries stand in when this one fails 

431 attrs[CONF_FALLBACK] = self.fallback 

432 if self.alias: 

433 attrs[ATTR_FRIENDLY_NAME] = self.alias 

434 if self.last_sent_at is not None: 

435 attrs[ATTR_LAST_SENT_AT] = self.last_sent_at 

436 if self.last_outcome is not None: 

437 attrs[ATTR_LAST_OUTCOME] = self.last_outcome 

438 if self.last_skip_reason is not None: 

439 attrs[ATTR_LAST_SKIP_REASON] = self.last_skip_reason 

440 return attrs 

441 

442 

443class DeliveryRegistry: 

444 def __init__( 

445 self, 

446 deliveries: ConfigType | None = None, 

447 transport_configs: ConfigType | None = None, 

448 transport_types: list[type[Transport]] | dict[type[Transport], dict[str, Any]] | None = None, 

449 # for unit tests only 

450 transport_instances: list[Transport] | None = None, 

451 delivery_control: ConfigType | None = None, 

452 ) -> None: 

453 # raw configured deliveries 

454 self._config_deliveries: ConfigType = deliveries if isinstance(deliveries, dict) else {} 

455 # validated deliveries 

456 self._deliveries: dict[str, Delivery] = {} 

457 self.transports: dict[str, Transport] = {} 

458 self._transport_configs: ConfigType = transport_configs or {} 

459 # The deprecated delivery and transport binary_sensors, by unique_id - populated by 

460 # binary_sensor.py as each is added, only for an existing install that still has them 

461 self._entities: dict[str, SupernotifyLegacyBinarySensor] = {} 

462 

463 self._transport_types: dict[type[Transport], dict[str, Any]] 

464 if isinstance(transport_types, list): 

465 self._transport_types = {t: {} for t in transport_types} 

466 else: 

467 self._transport_types = transport_types or {} 

468 # test harness support 

469 self._transport_instances: list[Transport] | None = transport_instances 

470 # the Delivery Control options, each None when not set, leaving transports' own defaults 

471 delivery_control = delivery_control or {} 

472 self.default_inclusion: str | None = delivery_control.get(CONF_DEFAULT_INCLUSION) 

473 self.voice_occupancy: str | None = delivery_control.get(CONF_VOICE_OCCUPANCY) 

474 

475 async def initialize(self, context: Context) -> None: 

476 await self.initialize_transports(context) 

477 

478 def unload_unused_transports(self) -> None: 

479 """Drop any transport that ended up with no delivery at all - explicit or auto-generated. 

480 

481 Deliberately deferred until both initialize_transport_deliveries() (explicit) and 

482 build_standard_deliveries() (implicit) have run, rather than decided per-transport up 

483 front: whether a transport is worth having can only be known once the full, resolved 

484 set of deliveries exists - for a transport like `generic` (bring-your-own-action, 

485 entirely delivery-driven), there's no transport-level state to check in advance at all. 

486 """ 

487 used_transport_names = {d.transport.name for d in self._deliveries.values()} 

488 for name in list(self.transports): 

489 if name not in used_transport_names: 

490 # routine for a transport never mentioned in config, only worth an info if it was 

491 level = logging.INFO if name in self._transport_configs else logging.DEBUG 

492 _LOGGER.log(level, "SUPERNOTIFY %s transport has no deliveries, unloading", name) 

493 del self.transports[name] 

494 

495 def register_entity(self, unique_id: str, entity: SupernotifyLegacyBinarySensor) -> None: 

496 """Called by a delivery or transport binary_sensor's async_added_to_hass().""" 

497 self._entities[unique_id] = entity 

498 

499 def unregister_entity(self, unique_id: str) -> None: 

500 """Called by a delivery or transport binary_sensor's async_will_remove_from_hass().""" 

501 self._entities.pop(unique_id, None) 

502 

503 def legacy_entities(self) -> list[SupernotifyLegacyBinarySensor]: 

504 """Every registered delivery and transport binary_sensor - used by supernotify.refresh_entities.""" 

505 return list(self._entities.values()) 

506 

507 @callback 

508 def async_refresh_entity(self, unique_id: str) -> None: 

509 """Re-publish one delivery or transport binary_sensor now, by its unique_id""" 

510 entity = self._entities.get(unique_id) 

511 if entity is not None: 

512 entity.async_write_ha_state() 

513 

514 @property 

515 def deliveries(self) -> dict[str, Delivery]: 

516 return dict(self._deliveries.items()) 

517 

518 def resolve_name(self, name: str) -> str: 

519 """Backward compatibility for the original 'DEFAULT_x' auto-configured naming, 

520 long since replaced by plain transport names: a reference to the old 'DEFAULT_x' 

521 form resolves to the current 'x' delivery, if that's what actually exists now.""" 

522 if name not in self._deliveries and name.startswith("DEFAULT_"): 

523 plain_name = name.removeprefix("DEFAULT_") 

524 if plain_name in self._deliveries: 

525 return plain_name 

526 return name 

527 

528 @property 

529 def enabled_deliveries(self) -> dict[str, Delivery]: 

530 return {d: dconf for d, dconf in self._deliveries.items() if dconf.enabled} 

531 

532 @property 

533 def disabled_deliveries(self) -> dict[str, Delivery]: 

534 return {d: dconf for d, dconf in self._deliveries.items() if not dconf.enabled} 

535 

536 # Computed on each call, not cached at startup, so that a delivery enabled at runtime (by its 

537 # switch) is included just like one enabled in config. delivery.inclusion can also be 

538 # INCLUSION_BY_SCENARIO or INCLUSION_EXPLICIT to have it only used where asked for. 

539 

540 @property 

541 def fallback_by_default_deliveries(self) -> list[Delivery]: 

542 return [d for d in self._deliveries.values() if d.enabled and INCLUSION_FALLBACK in d.inclusion] 

543 

544 @property 

545 def fallback_on_error_deliveries(self) -> list[Delivery]: 

546 return [d for d in self._deliveries.values() if d.enabled and INCLUSION_FALLBACK_ON_ERROR in d.inclusion] 

547 

548 @property 

549 def choosable_deliveries(self) -> dict[str, Delivery]: 

550 """Deliveries a notification can ask for - not ones only a scenario turns on, or only a fallback. 

551 What the action editor's Delivery list and the LLM tools offer.""" 

552 return { 

553 name: d for name, d in self._deliveries.items() if {INCLUSION_DEFAULT, INCLUSION_EXPLICIT}.intersection(d.inclusion) 

554 } 

555 

556 @property 

557 def implicit_deliveries(self) -> list[Delivery]: 

558 """Deliveries actually capable of implicit inclusion - for an auto-generated standard 

559 delivery, `default` inclusion alone isn't enough any more, its transport also has to 

560 declare a `unique_target_categories` or `fallback_target_categories` for 

561 `Notification.select_deliveries()` to ever actually match against; without one, a 

562 nominally `default` standard delivery (most transports that don't say otherwise) 

563 structurally can never be auto-selected, the same as an explicit-only one. A delivery 

564 whose own config explicitly declared `inclusion: default` (`Delivery.explicit_ 

565 inclusion`) is exempt from that check - that's already a deliberate, explicit choice, 

566 not something merely inherited from the transport's now-universal bare `default` 

567 (`TransportConfig()`'s own constructor, `model.py` - `Transport.inclusion_mode` used to 

568 override this down to `explicit` for most transports; now that it's gone, every 

569 transport that doesn't declare categories or set its own `delivery_defaults.inclusion` 

570 nominally inherits `default` too, without it being a deliberate choice by anyone), so 

571 it's trusted at face value exactly as before this mechanism existed (e.g. a fixed-action 

572 `generic` delivery with no target concept at all, meant to fire on every notification). 

573 A delivery that leaves `inclusion:` unset entirely still goes through the category 

574 check, even though it inherits the same nominal `default` value - inheriting isn't 

575 choosing. This is the single source of truth for "implicit-capable" - both the actual 

576 selection logic and this reporting property (the action editor's implied default list, 

577 `enquire_implicit_deliveries`, diagnostics) read it.""" 

578 return [ 

579 d 

580 for d in self._deliveries.values() 

581 if d.enabled 

582 and INCLUSION_DEFAULT in d.inclusion 

583 and (d.explicit_inclusion or d.transport.unique_target_categories or d.transport.fallback_target_categories) 

584 ] 

585 

586 def apply_delivery_control(self, transport: Transport, transport_config: ConfigType) -> None: 

587 """Give a transport's deliveries the Delivery Control defaults, where the transport's own 

588 YAML `delivery_defaults` doesn't set them. A delivery's own YAML still wins over both.""" 

589 own: ConfigType = transport_config.get(CONF_DELIVERY_DEFAULTS) or {} 

590 defaults: DeliveryConfig = transport.delivery_defaults 

591 if self.default_inclusion and CONF_INCLUSION not in own: 

592 defaults.inclusion = [self.default_inclusion] 

593 if self.voice_occupancy and CONF_OCCUPANCY not in own and transport.supported_features & TransportFeature.SPOKEN: 

594 defaults.occupancy = self.voice_occupancy 

595 

596 async def initialize_transports(self, context: Context) -> None: 

597 if self._transport_instances: 

598 """Used by configure_for_tests() and TestingContext to set transports to mocks or manually created fixtures""" 

599 for transport in self._transport_instances: 

600 self.transports[transport.name] = transport 

601 await transport.initialize() 

602 await self.initialize_transport_deliveries(context, transport) 

603 

604 if self._transport_types: 

605 # production usage 

606 for transport_class, kwargs in self._transport_types.items(): 

607 transport_config: ConfigType = self._transport_configs.get(transport_class.name, {}) 

608 if not transport_config.get(CONF_LOAD, True): 

609 # not just disabled: excluded entirely, so no deliveries or entities either 

610 _LOGGER.debug("SUPERNOTIFY %s transport configured not to load", transport_class.name) 

611 continue 

612 transport = transport_class(context, transport_config, **kwargs) 

613 self.apply_delivery_control(transport, transport_config) 

614 if not transport.is_viable(context.hass_api): 

615 # quietly skip transports for integrations that aren't installed at all 

616 level = ( 

617 logging.DEBUG 

618 if transport.integration_domain 

619 and not context.hass_api.integration_loaded(transport.integration_domain) 

620 else logging.INFO 

621 ) 

622 _LOGGER.log(level, "SUPERNOTIFY %s transport has no viable configuration, not loaded", transport_class.name) 

623 continue 

624 self.transports[transport_class.name] = transport 

625 await transport.initialize() 

626 await self.initialize_transport_deliveries(context, transport) 

627 self.transports[transport_class.name] = transport 

628 

629 unconfigured_deliveries = [dc for d, dc in self._config_deliveries.items() if d not in self._deliveries] 

630 for bad_del in unconfigured_deliveries: 

631 # presumably there was no transport for these 

632 context.hass_api.raise_issue( 

633 f"delivery_{bad_del.get(CONF_NAME)}_for_transport_{bad_del.get(CONF_TRANSPORT)}_failed_to_configure", 

634 issue_key="delivery_unknown_transport", 

635 issue_map={ 

636 "delivery": bad_del.get(CONF_NAME), 

637 "transport": bad_del.get(CONF_TRANSPORT), 

638 "transports": ", ".join(sorted(self.transports)), 

639 }, 

640 learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/deliveries/", 

641 ) 

642 

643 self.unload_unused_transports() 

644 for delivery in self._deliveries.values(): 

645 for name in delivery.fallback: 

646 if name not in self._deliveries or name == delivery.name: 

647 _LOGGER.warning( 

648 "SUPERNOTIFY Delivery %s has fallback %s, which is not another delivery", delivery.name, name 

649 ) 

650 else: 

651 self._deliveries[name].is_fallback = True 

652 _LOGGER.info("SUPERNOTIFY Configured deliveries %s", "; ".join(self._deliveries.keys())) 

653 

654 async def initialize_transport_deliveries(self, context: Context, transport: Transport) -> None: 

655 """Validate and initialize deliveries at startup for this transport""" 

656 validated_deliveries: dict[str, Delivery] = {} 

657 configured_deliveries: dict[str, ConfigType] = { 

658 d: dc for d, dc in self._config_deliveries.items() if dc.get(CONF_TRANSPORT) == transport.name 

659 } 

660 # hackily put here, since build_standard_deliveries can have side-effect of updating default deliveries 

661 standard_deliveries: dict[str, ConfigType] = transport.build_standard_deliveries(context.hass_api) 

662 

663 for d, dc in configured_deliveries.items(): 

664 # don't care about ENABLED here since disabled deliveries can be overridden later 

665 delivery = Delivery(d, dc, transport, DeliveryProvenance.CONFIG) 

666 if not await delivery.initialize(context): 

667 _LOGGER.error(f"SUPERNOTIFY Ignoring configured delivery {d} with errors") 

668 else: 

669 validated_deliveries[d] = delivery 

670 

671 # merge in remaining standard deliveries but allow local override 

672 for d, dc in standard_deliveries.items(): 

673 if d in configured_deliveries: 

674 _LOGGER.info("SUPERNOTIFY Default standard delivery %s overridden by config", d) 

675 else: 

676 provenance = DeliveryProvenance.DEFAULT_STANDARD if d == transport.name else DeliveryProvenance.EXTRA_STANDARD 

677 delivery = Delivery(d, dc, transport, provenance=provenance) 

678 

679 if not await delivery.initialize(context): 

680 _LOGGER.error(f"SUPERNOTIFY Ignoring standard delivery {d} with errors") 

681 else: 

682 validated_deliveries[d] = delivery 

683 

684 self._deliveries.update(validated_deliveries) 

685 

686 _LOGGER.debug( 

687 "SUPERNOTIFY Validated transport %s, default action %s, valid deliveries: %s", 

688 transport.name, 

689 transport.delivery_defaults.action, 

690 [d for d in self._deliveries.values() if d.enabled and d.transport == transport], 

691 )