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

337 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 typing import TYPE_CHECKING, Any 

5 

6import homeassistant.util.dt as dt_util 

7from homeassistant.components.notify import ( 

8 NotifyEntity, 

9 NotifyEntityFeature, 

10) 

11from homeassistant.components.person.const import DOMAIN as PERSON_DOMAIN 

12from homeassistant.const import ( 

13 ATTR_ENTITY_ID, 

14 ATTR_FRIENDLY_NAME, 

15 CONF_ALIAS, 

16 CONF_EMAIL, 

17 CONF_ENABLED, 

18 CONF_TARGET, 

19 STATE_HOME, 

20 STATE_NOT_HOME, 

21) 

22from homeassistant.core import callback 

23from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 

24from homeassistant.helpers.redact import partial_redact 

25from homeassistant.util import slugify 

26 

27from .common import ensure_list 

28from .const import ( 

29 ATTR_ALIAS, 

30 ATTR_EMAIL, 

31 ATTR_ENABLED, 

32 ATTR_MOBILE_APP_ID, 

33 ATTR_PERSON_ID, 

34 ATTR_PHONE, 

35 ATTR_USER_ID, 

36 CONF_DATA, 

37 CONF_DELIVERY, 

38 CONF_MOBILE_APP_ID, 

39 CONF_MOBILE_DEVICES, 

40 CONF_MOBILE_DISCOVERY, 

41 CONF_PERSON, 

42 CONF_PHONE_NUMBER, 

43 CONF_USER_ID, 

44 OCCUPANCY_ALL, 

45 OCCUPANCY_ALL_IN, 

46 OCCUPANCY_ALL_OUT, 

47 OCCUPANCY_ANY_IN, 

48 OCCUPANCY_ANY_OUT, 

49 OCCUPANCY_NONE, 

50 OCCUPANCY_ONLY_IN, 

51 OCCUPANCY_ONLY_OUT, 

52 SUBENTRY_TYPE_RECIPIENT, 

53) 

54from .model import DeliveryCustomization, NotifyEntityPlatform, Target 

55 

56if TYPE_CHECKING: 

57 from collections.abc import Mapping 

58 

59 from homeassistant.config_entries import ConfigSubentry 

60 from homeassistant.core import Context, State 

61 

62 from .binary_sensor import SupernotifyRecipientBinarySensor 

63 from .hass_api import HomeAssistantAPI, TrackedDeviceDetails 

64 

65 

66_LOGGER = logging.getLogger(__name__) 

67 

68 

69class RecipientNotifyEntity(NotifyEntity): 

70 """Expose a single recipient as its own `notify.recipient_<name>` entity. 

71 

72 Sent as an ordinary `target` on the main supernotify action, just like any other entity - 

73 Notification recognizes it as one of supernotify's own published recipient notify entities 

74 and resolves it to this recipient, still going through the full default-recipient delivery pipeline 

75 (occupancy, scenarios, personal delivery overrides, dedupe, snooze) rather than a literal, 

76 unscoped target override. Per HA's notify entity service schema, only message/title are 

77 ever passed in here - no data/target. 

78 """ 

79 

80 _attr_has_entity_name = True 

81 _attr_translation_key = "recipient" 

82 

83 def __init__( 

84 self, 

85 unique_id: str, 

86 recipient: Recipient, 

87 engine: NotifyEntityPlatform, 

88 ) -> None: 

89 """Initialize the recipient notify entity.""" 

90 self._attr_unique_id = unique_id 

91 self._attr_name = recipient.alias or recipient.name 

92 self._attr_supported_features = NotifyEntityFeature.TITLE 

93 self._recipient = recipient 

94 self._engine = engine 

95 self.entity_id = f"notify.recipient_{recipient.name}" 

96 # Fallback attribute for HA < 2026.3 (python_full_version < '3.14.2' in pyproject.toml 

97 # pins homeassistant==2026.2.3) - NotifyEntity there has no _async_record_notification() 

98 # and no other supported way to set its native `state` from outside a direct 

99 # notify.recipient_<name> service call (the logic lives in the @final, HA-framework-only 

100 # _async_send_message()). Left unused - and extra_state_attributes returns None - once 

101 # running against a HA version that has _async_record_notification(); see 

102 # record_notification() below for the runtime hasattr() check that picks a path. Support 

103 # for the pre-2026.3 lane is time-limited: see the "python_313_deprecated" repair issue, 

104 # dropped when HA 2026.10 ships - this whole fallback (and the property below) can go 

105 # once that lane is gone. 

106 self._last_notified: str | None = None 

107 

108 @property 

109 def extra_state_attributes(self) -> dict[str, Any] | None: 

110 """Only populated on the pre-2026.3 fallback path - see record_notification().""" 

111 if hasattr(self, "_async_record_notification"): 

112 return None 

113 return {"last_notified": self._last_notified} 

114 

115 def record_notification(self, context: Context | None = None) -> None: 

116 """Record that this recipient was notified via supernotify.notify's main pipeline 

117 (any target - person_id, email, mobile device...), not just via a direct call to 

118 this notify.recipient_<name> entity. Called from Notification.record_result(). 

119 

120 Delegates to NotifyEntity._async_record_notification() when available - the same 

121 HA-native method html5/mobile_app call on a direct notify.recipient_<name> service 

122 call - so `state` reflects the most recent delivery via either path. On HA versions 

123 before 2026.3 that method doesn't exist (see the docstring on self._last_notified in 

124 __init__), so this falls back to the pre-refactor design: a separate extra_state_attributes 

125 attribute, restored manually in async_added_to_hass() below. Either way, a message routed 

126 through supernotify.notify's main pipeline (the common case) never invokes this entity's 

127 own async_send_message() - see convert_notify_entities() in notification.py, which 

128 short-circuits that target straight to a person_id to avoid calling back into this same 

129 entity in a loop - so without this explicit call, delivery via that pipeline would never 

130 be reflected here at all. 

131 

132 `context` is the calling HA service context, if any, so the state change is attributed to 

133 the action call (or automation, or user) that caused it, in the logbook and history, just 

134 as it would be for a direct notify.recipient_<name> service call.""" 

135 if context is not None: 

136 self.async_set_context(context) 

137 if hasattr(self, "_async_record_notification"): 

138 self._async_record_notification() 

139 else: 

140 self._last_notified = dt_util.utcnow().isoformat() 

141 self.async_write_ha_state() 

142 

143 async def async_added_to_hass(self) -> None: 

144 await super().async_added_to_hass() 

145 self._recipient.notify_entity_id = self.entity_id 

146 self._recipient.notify_entity = self 

147 # Restoring `state` after a restart is handled for us by 

148 # NotifyEntity.async_internal_added_to_hass() when _async_record_notification() is 

149 # available. On the pre-2026.3 fallback path there's no such restore, so do it manually 

150 # here, same as before the refactor. 

151 if not hasattr(self, "_async_record_notification"): 

152 last_state = await self.async_get_last_state() 

153 if last_state is not None: 

154 self._last_notified = last_state.attributes.get("last_notified") 

155 

156 async def async_will_remove_from_hass(self) -> None: 

157 self._recipient.notify_entity_id = None 

158 self._recipient.notify_entity = None 

159 await super().async_will_remove_from_hass() 

160 

161 async def async_send_message(self, message: str, title: str | None = None) -> None: 

162 """Send a message to this recipient.""" 

163 await self._engine.async_send_message( 

164 message, title=title, target=self.entity_id, context=self._context, via_notify=self.entity_id 

165 ) 

166 

167 

168def recipient_key(config: dict[str, Any]) -> str | None: 

169 """What identifies a recipient's configuration - its person, else its user, else for 

170 someone who isn't a Home Assistant user, the synthetic `user.` id its alias gives it""" 

171 if person := config.get(CONF_PERSON): 

172 return str(person) 

173 if user_id := config.get(CONF_USER_ID): 

174 return str(user_id) 

175 if alias := config.get(CONF_ALIAS): 

176 return f"user.{slugify(alias)}" 

177 return None 

178 

179 

180class Recipient: 

181 """Recipient to distinguish from the native HA Person. 

182 

183 The "future native entity use" this class was once staged for (BinarySensorDeviceClass, 

184 EntityCategory, etc.) has arrived as SupernotifyRecipientBinarySensor in binary_sensor.py - 

185 a wrapper Entity holding a reference to a Recipient, the same composition already used for 

186 RecipientNotifyEntity above, rather than this plain domain object inheriting from Entity. 

187 """ 

188 

189 def __init__(self, config: dict[str, Any] | None, default_mobile_discovery: bool = True) -> None: 

190 config = config or {} 

191 self.alias: str | None = config.get(CONF_ALIAS) 

192 self.email: str | None = config.get(CONF_EMAIL) 

193 self.phone_number: str | None = config.get(CONF_PHONE_NUMBER) 

194 self.user_id: str | None = config.get(CONF_USER_ID) 

195 # A recipient normally is a native HA Person, but CONF_PERSON is optional - a household 

196 # that only sets up Users (no Person records) still has a working, addressable recipient 

197 # via CONF_USER_ID alone. entity_id is then a synthetic, never-registered id (`user.<name>` 

198 # rather than `person.<name>` - both kinds of config are still "recipients", so that word 

199 # alone wouldn't distinguish them), used purely as this recipient's internal key (person_id 

200 # target category, notify/binary_sensor/switch entity naming, snooze/delivery-override 

201 # lookups) - see also the "impossible for a real HA entity to exist" cases these lookups 

202 # already handle gracefully, e.g. PeopleRegistry person_attributes()/ 

203 # _fetch_person_entity_state() returning None for an unknown entity_id. 

204 self.entity_id: str = config.get(CONF_PERSON) or "" 

205 # what identifies its configuration, and so its subentry, see recipient_key() 

206 self.key: str | None = recipient_key(config) 

207 # Without a Person there's no presence, so the recipient is left out of occupancy 

208 self.has_person: bool = bool(self.entity_id) 

209 if self.entity_id: 

210 self.name: str = self.entity_id.replace("person.", "") 

211 else: 

212 self.name = slugify(self.alias or self.user_id or "") 

213 self.entity_id = f"user.{self.name}" 

214 self.notify_entity_id: str | None = None 

215 # Set/cleared by RecipientNotifyEntity.async_added_to_hass()/async_will_remove_from_hass() 

216 # - the live entity object itself, so record_notification() can be called directly from 

217 # Notification.record_result() without a registry lookup by entity_id. 

218 self.notify_entity: RecipientNotifyEntity | None = None 

219 

220 self._target: Target = Target(config.get(CONF_TARGET, {}), target_data=config.get(CONF_DATA)) 

221 self.delivery_overrides: dict[str, DeliveryCustomization] = { 

222 k: DeliveryCustomization(config=v, target_specific=True) for k, v in config.get(CONF_DELIVERY, {}).items() 

223 } 

224 self.enabled: bool = config.get(CONF_ENABLED, True) 

225 # as configured, which enabled can be overridden from at runtime by the recipient switch 

226 self.config_enabled: bool = self.enabled 

227 self.mobile_discovery: bool = config.get(CONF_MOBILE_DISCOVERY, default_mobile_discovery) 

228 self.mobile_devices: dict[str, dict[str, str | list[str] | None]] = { 

229 c[CONF_MOBILE_APP_ID]: c for c in config.get(CONF_MOBILE_DEVICES, []) 

230 } 

231 self.disabled_mobile_app_ids: list[str] = [k for k, v in self.mobile_devices.items() if not v.get(CONF_ENABLED, True)] 

232 # as configured, to start again from each time the discovered devices are refreshed 

233 self._configured_mobile_devices: dict[str, dict[str, str | list[str] | None]] = { 

234 k: dict(v) for k, v in self.mobile_devices.items() 

235 } 

236 self._mobile_app_issues: set[str] = set() 

237 _LOGGER.debug("SUPERNOTIFY Recipient config %s -> %s", config, self.as_dict(redact=True)) 

238 

239 def initialize(self, people_registry: PeopleRegistry) -> None: 

240 

241 self._target.extend(ATTR_PERSON_ID, [self.entity_id]) 

242 if self.email: 

243 self._target.extend(ATTR_EMAIL, self.email) 

244 if self.phone_number: 

245 self._target.extend(ATTR_PHONE, self.phone_number) 

246 self.resolve_mobile_devices(people_registry) 

247 if not self.user_id or not self.alias: 

248 attrs: dict[str, Any] | None = people_registry.person_attributes(self.entity_id) 

249 if attrs: 

250 if attrs.get(ATTR_USER_ID) and isinstance(attrs.get(ATTR_USER_ID), str): 

251 self.user_id = attrs.get(ATTR_USER_ID) 

252 if attrs.get(ATTR_ALIAS) and isinstance(attrs.get(ATTR_ALIAS), str): 

253 self.alias = attrs.get(ATTR_ALIAS) 

254 if not self.alias and attrs.get(ATTR_FRIENDLY_NAME) and isinstance(attrs.get(ATTR_FRIENDLY_NAME), str): 

255 self.alias = attrs.get(ATTR_FRIENDLY_NAME) 

256 _LOGGER.debug("SUPERNOTIFY Person attrs found for %s: %s,%s", self.entity_id, self.alias, self.user_id) 

257 else: 

258 _LOGGER.debug("SUPERNOTIFY No person attrs found for %s", self.entity_id) 

259 _LOGGER.debug("SUPERNOTIFY Recipient %s target: %s", self.entity_id, self._target.as_dict(redact=True)) 

260 

261 def on_notification(self, context: Context | None = None) -> None: 

262 # Record that a notification has occurred for this person 

263 if self.notify_entity is not None: 

264 self.notify_entity.record_notification(context) 

265 

266 def resolve_mobile_devices(self, people_registry: PeopleRegistry) -> None: 

267 """The configured mobile devices plus those discovered now - again whenever one is re-paired or renamed""" 

268 previous: list[str] = list(self.mobile_devices) 

269 self.mobile_devices = {k: dict(v) for k, v in self._configured_mobile_devices.items()} 

270 # someone who isn't a Home Assistant user has no mobile app devices to discover 

271 if self.mobile_discovery and (self.user_id or self.has_person): 

272 # a known user_id (explicitly configured, or already backfilled below from a prior 

273 # initialize()) resolves devices directly, without needing a Person entity at all 

274 discovered_devices: list[TrackedDeviceDetails] = ( 

275 people_registry.mobile_devices_for_user(self.user_id) 

276 if self.user_id 

277 else people_registry.mobile_devices_for_person(self.entity_id) 

278 ) 

279 if discovered_devices: 

280 new_ids = [] 

281 for d in discovered_devices: 

282 if d.mobile_app_id in self.mobile_devices: 

283 # merge with manual registrations, with priority to manually overridden values 

284 merged = d.as_dict() 

285 merged.update(self.mobile_devices[d.mobile_app_id]) 

286 self.mobile_devices[d.mobile_app_id] = merged 

287 new_ids.append(d.mobile_app_id) 

288 _LOGGER.debug("SUPERNOTIFY Updating %s mobile device %s from registry", self.entity_id, d.mobile_app_id) 

289 elif d.mobile_app_id is not None and d.action: 

290 # a mobile app with no notify action can't be notified, and may never be, 

291 # like an Apple Watch, which registers with Home Assistant without a push token 

292 self.mobile_devices[d.mobile_app_id] = d.as_dict() 

293 new_ids.append(d.mobile_app_id) 

294 _LOGGER.info( 

295 "SUPERNOTIFY Auto configured %s for mobile devices %s", 

296 self.entity_id, 

297 ",".join(new_ids), 

298 ) 

299 else: 

300 _LOGGER.info("SUPERNOTIFY Unable to find mobile devices for %s", self.entity_id) 

301 self._target.remove(ATTR_MOBILE_APP_ID, previous) 

302 if self.mobile_devices: 

303 self._target.extend(ATTR_MOBILE_APP_ID, list(self.enabled_mobile_devices.keys())) 

304 self.check_mobile_actions(people_registry.hass_api) 

305 

306 def check_mobile_actions(self, hass_api: HomeAssistantAPI) -> None: 

307 """Raise a repair for a configured, enabled mobile device with no notify action, and clear it once that's resolved 

308 

309 Only for the ones listed under `mobile_devices` - a discovered one has nothing for the user to repair 

310 """ 

311 missing: set[str] = set() 

312 for mobile_app_id in self.enabled_mobile_devices: 

313 if mobile_app_id not in self._configured_mobile_devices: 

314 continue 

315 action: str = mobile_app_id.removeprefix("notify.") 

316 if not hass_api.mobile_app_action_exists(action): 

317 issue_id: str = f"recipient_{self.name}_mobile_app_{action}_not_found" 

318 missing.add(issue_id) 

319 if issue_id not in self._mobile_app_issues: 

320 _LOGGER.warning("SUPERNOTIFY No notify action found for %s mobile device %s", self.entity_id, action) 

321 hass_api.raise_issue( 

322 issue_id, 

323 issue_key="recipient_mobile_app_not_found", 

324 issue_map={"recipient": self.entity_id, "mobile_app": action}, 

325 learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/people/", 

326 ) 

327 for issue_id in self._mobile_app_issues - missing: 

328 hass_api.delete_issue(issue_id) 

329 self._mobile_app_issues = missing 

330 

331 @property 

332 def enabled_mobile_devices(self) -> dict[str, dict[str, str | list[str] | None]]: 

333 return {k: v for k, v in self.mobile_devices.items() if v.get(CONF_ENABLED, True)} 

334 

335 def enabling_delivery_names(self) -> list[str]: 

336 """Explicitly overriding enabled state""" 

337 return [ 

338 delname 

339 for delname, delconf in self.delivery_overrides.items() 

340 if delconf.enabled is not None and delconf.enabled is True 

341 ] 

342 

343 def disabling_delivery_names(self) -> list[str]: 

344 """Explicitly overriding enabled state""" 

345 return [ 

346 delname 

347 for delname, delconf in self.delivery_overrides.items() 

348 if delconf.enabled is not None and delconf.enabled is False 

349 ] 

350 

351 def target(self, delivery_name: str) -> Target: 

352 recipient_target: Target = self._target 

353 personal_delivery: DeliveryCustomization | None = self.delivery_overrides.get(delivery_name) 

354 if personal_delivery and personal_delivery.enabled is not False: 

355 if personal_delivery.target and personal_delivery.target.has_targets(): 

356 recipient_target += personal_delivery.target 

357 if personal_delivery.data: 

358 recipient_target += Target([], target_data=personal_delivery.data, target_specific_data=True) 

359 return recipient_target 

360 

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

362 result = {CONF_PERSON: self.entity_id, CONF_ENABLED: self.enabled} 

363 if not occupancy_only: 

364 result.update({ 

365 CONF_ALIAS: self.alias, 

366 CONF_EMAIL: self.email, 

367 CONF_PHONE_NUMBER: self.phone_number, 

368 ATTR_USER_ID: self.user_id, 

369 CONF_MOBILE_DISCOVERY: self.mobile_discovery, 

370 CONF_MOBILE_DEVICES: list(self.mobile_devices.values()), 

371 CONF_TARGET: self._target.as_dict() if self._target else None, 

372 CONF_DELIVERY: {d: c.as_dict() for d, c in self.delivery_overrides.items()} 

373 if self.delivery_overrides 

374 else None, 

375 }) 

376 if redact: 

377 for k in (CONF_EMAIL, CONF_PHONE_NUMBER): 

378 if k in result: 

379 result[k] = partial_redact(result[k], unmasked_prefix=2, unmasked_suffix=1) 

380 return result 

381 

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

383 """For exposure as entity state""" 

384 attrs: dict[str, Any] = { 

385 ATTR_ENTITY_ID: self.entity_id, 

386 ATTR_ENABLED: self.enabled, 

387 CONF_EMAIL: self.email, 

388 CONF_PHONE_NUMBER: self.phone_number, 

389 ATTR_USER_ID: self.user_id, 

390 CONF_MOBILE_DEVICES: list(self.mobile_devices.values()), 

391 CONF_MOBILE_DISCOVERY: self.mobile_discovery, 

392 CONF_TARGET: self._target, 

393 CONF_DELIVERY: self.delivery_overrides, 

394 } 

395 if self.alias: 

396 attrs[ATTR_FRIENDLY_NAME] = self.alias 

397 return attrs 

398 

399 

400class PeopleRegistry: 

401 def __init__( 

402 self, 

403 recipients: list[dict[str, Any]], 

404 hass_api: HomeAssistantAPI, 

405 discover: bool = False, 

406 mobile_discovery: bool = True, 

407 ) -> None: 

408 self.hass_api = hass_api 

409 self.people: dict[str, Recipient] = {} 

410 self._recipients: list[dict[str, Any]] = ensure_list(recipients) 

411 self.mobile_discovery = mobile_discovery 

412 self.discover = discover 

413 # the configuration of each recipient discovered, by recipient_key(), before any configured 

414 # recipient is merged over it 

415 self.discovered: dict[str, dict[str, Any]] = {} 

416 # Populated by binary_sensor.py's async_setup_entry once the platform is loaded - see 

417 # register_entity/unregister_entity. Empty (and harmless to look up against) before 

418 # then, and in tests that build PeopleRegistry directly without a config entry. 

419 self._entities: dict[str, SupernotifyRecipientBinarySensor] = {} 

420 

421 async def initialize(self) -> None: 

422 recipients: dict[str, dict[str, Any]] = {} 

423 if self.discover: 

424 entity_ids = self.find_people() 

425 person_user_ids: set[str] = set() 

426 if entity_ids: 

427 recipients = {entity_id: {CONF_PERSON: entity_id} for entity_id in entity_ids} 

428 for entity_id in entity_ids: 

429 attrs = self.person_attributes(entity_id) 

430 if attrs and isinstance(attrs.get(ATTR_USER_ID), str): 

431 person_user_ids.add(attrs[ATTR_USER_ID]) 

432 _LOGGER.info("SUPERNOTIFY Auto-discovered people: %s", entity_ids) 

433 

434 # Users are the essential HA concept - mobile_app itself only requires one, a Person 

435 # is a separate, optional layer (see CONF_USER_ID in const.py) - so a real user with 

436 # no matching Person here is still discoverable, just without Person's presence 

437 # tracking/UI. A user already represented by a Person above is not duplicated. 

438 real_user_ids = await self.hass_api.async_real_user_ids() 

439 user_only_ids = {uid: name for uid, name in real_user_ids.items() if uid not in person_user_ids} 

440 if user_only_ids: 

441 recipients.update({uid: {CONF_USER_ID: uid, CONF_ALIAS: name} for uid, name in user_only_ids.items()}) 

442 _LOGGER.info("SUPERNOTIFY Auto-discovered user-only accounts: %s", list(user_only_ids.values())) 

443 self.discovered = {k: dict(v) for k, v in recipients.items()} 

444 

445 for r in self._recipients: 

446 # merge/dedupe key only - the recipient's real identity (entity_id) is derived by 

447 # Recipient itself, below, from whichever of these was given 

448 key = recipient_key(r) 

449 if not key: 

450 _LOGGER.warning("SUPERNOTIFY Skipping invalid recipient with no 'person', 'user_id' or 'alias':%s", r) 

451 continue 

452 if key in recipients: 

453 _LOGGER.debug("SUPERNOTIFY Overriding %s entity defaults from recipient config", key) 

454 recipients[key].update(r) 

455 else: 

456 recipients[key] = r 

457 

458 for r in recipients.values(): 

459 recipient: Recipient = Recipient(r, default_mobile_discovery=self.mobile_discovery) 

460 recipient.initialize(self) 

461 

462 self.people[recipient.entity_id] = recipient 

463 

464 def refresh_mobile_devices(self) -> None: 

465 """Rediscover every recipient's mobile devices, after one was added, removed, re-paired or renamed""" 

466 self.hass_api.build_mobile_app_cache() 

467 for recipient in self.people.values(): 

468 recipient.resolve_mobile_devices(self) 

469 self.async_refresh_entity(recipient.name) 

470 

471 def register_entity(self, name: str, entity: SupernotifyRecipientBinarySensor) -> None: 

472 """Called by SupernotifyRecipientBinarySensor.async_added_to_hass().""" 

473 self._entities[name] = entity 

474 

475 def unregister_entity(self, name: str) -> None: 

476 """Called by SupernotifyRecipientBinarySensor.async_will_remove_from_hass().""" 

477 self._entities.pop(name, None) 

478 

479 def recipient_entities(self) -> list[SupernotifyRecipientBinarySensor]: 

480 """Every registered recipient binary_sensor - used by supernotify.refresh_entities.""" 

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

482 

483 @callback 

484 def async_refresh_entity(self, name: str) -> None: 

485 """Re-publish one recipient's binary_sensor now""" 

486 entity = self._entities.get(name) 

487 if entity is not None: 

488 entity.async_write_ha_state() 

489 

490 def expose_notify_entities( 

491 self, 

492 entry_id: str, 

493 async_add_entities: AddConfigEntryEntitiesCallback, 

494 service: NotifyEntityPlatform, 

495 subentry_ids: dict[str, str], 

496 ) -> None: 

497 """A notify entity for each recipient, in its subentry where it has one, with no device, so 

498 a recipient is only listed once in the integration, as its subentry""" 

499 for recipient in self.people.values(): 

500 entity = RecipientNotifyEntity(f"{entry_id}_recipient_{recipient.name}", recipient, service) 

501 async_add_entities([entity], config_subentry_id=subentry_ids.get(recipient.key or "")) 

502 

503 def subentry_ids(self, subentries: Mapping[str, ConfigSubentry]) -> dict[str, str]: 

504 """The id of each recipient subentry, by recipient_key() of its configuration""" 

505 return { 

506 key: subentry.subentry_id 

507 for subentry in subentries.values() 

508 if subentry.subentry_type == SUBENTRY_TYPE_RECIPIENT and (key := recipient_key(dict(subentry.data))) 

509 } 

510 

511 def person_attributes(self, entity_id: str) -> dict[str, Any] | None: 

512 state: State | None = self.hass_api.get_state(entity_id) 

513 if state is not None and state.attributes: 

514 return state.attributes 

515 return None 

516 

517 def name_for_user_id(self, user_id: str) -> str | None: 

518 for recipient in self.people.values(): 

519 if recipient.user_id == user_id: 

520 return recipient.alias or recipient.name 

521 return None 

522 

523 def people_named(self, name: str) -> list[Recipient]: 

524 """The recipients called this, by alias or name, ignoring case and underscores - or failing 

525 that, those with it as their first name, so 'jey' finds 'Jey Burrows'""" 

526 wanted: str = name.replace("_", " ").casefold().strip() 

527 full_names: dict[str, set[str]] = { 

528 recipient.entity_id: {n.replace("_", " ").casefold().strip() for n in (recipient.alias, recipient.name) if n} 

529 for recipient in self.people.values() 

530 } 

531 if exact := [r for r in self.people.values() if wanted in full_names[r.entity_id]]: 

532 return exact 

533 return [r for r in self.people.values() if wanted in {n.split(" ")[0] for n in full_names[r.entity_id]}] 

534 

535 def person_id_for_name(self, name: str) -> str | None: 

536 """The one recipient called this, by full name, alias or unique first name""" 

537 named: list[Recipient] = self.people_named(name) 

538 return named[0].entity_id if len(named) == 1 else None 

539 

540 def person_id_for_user_id(self, user_id: str | None) -> str | None: 

541 for recipient in self.people.values(): 

542 if user_id and recipient.user_id == user_id: 

543 return recipient.entity_id 

544 return None 

545 

546 def find_people(self) -> list[str]: 

547 return self.hass_api.entity_ids_for_domain(PERSON_DOMAIN) 

548 

549 def notify_entities(self) -> dict[str, Recipient]: 

550 return {p.notify_entity_id: p for p in self.people.values() if p.notify_entity_id} 

551 

552 def enabled_recipients(self) -> list[Recipient]: 

553 return [p for p in self.people.values() if p.enabled] 

554 

555 def occupancy_permits_delivery(self, delivery_occupancy: str, occupancy: dict[str, list[Recipient]]) -> bool: 

556 """Whether a delivery should be attempted at all under the given occupancy, as opposed 

557 to filter_recipients_by_occupancy() below, which narrows *which* recipients within an 

558 already-selected delivery get targeted. `only_in`/`only_out` collapse to `any_in`/ 

559 `any_out` here: they narrow which recipients to target, but don't change whether the 

560 delivery itself is relevant, which is all a gate can meaningfully decide.""" 

561 if delivery_occupancy == OCCUPANCY_NONE: 

562 return False 

563 if delivery_occupancy == OCCUPANCY_ALL: 

564 return True 

565 away = len(occupancy[STATE_NOT_HOME]) 

566 at_home = len(occupancy[STATE_HOME]) 

567 if delivery_occupancy == OCCUPANCY_ALL_IN: 

568 return away == 0 

569 if delivery_occupancy == OCCUPANCY_ALL_OUT: 

570 return at_home == 0 

571 if delivery_occupancy in (OCCUPANCY_ANY_IN, OCCUPANCY_ONLY_IN): 

572 return at_home > 0 

573 if delivery_occupancy in (OCCUPANCY_ANY_OUT, OCCUPANCY_ONLY_OUT): 

574 return away > 0 

575 _LOGGER.warning("SUPERNOTIFY Unknown occupancy tested: %s", delivery_occupancy) 

576 return True 

577 

578 def filter_recipients_by_occupancy(self, delivery_occupancy: str) -> list[Recipient]: 

579 if delivery_occupancy == OCCUPANCY_NONE: 

580 return [] 

581 

582 people = [p for p in self.people.values() if p.enabled] 

583 if delivery_occupancy == OCCUPANCY_ALL: 

584 return people 

585 

586 occupancy = self.determine_occupancy() 

587 

588 away = occupancy[STATE_NOT_HOME] 

589 at_home = occupancy[STATE_HOME] 

590 if delivery_occupancy == OCCUPANCY_ALL_IN: 

591 return people if len(away) == 0 else [] 

592 if delivery_occupancy == OCCUPANCY_ALL_OUT: 

593 return people if len(at_home) == 0 else [] 

594 if delivery_occupancy == OCCUPANCY_ANY_IN: 

595 return people if len(at_home) > 0 else [] 

596 if delivery_occupancy == OCCUPANCY_ANY_OUT: 

597 return people if len(away) > 0 else [] 

598 if delivery_occupancy == OCCUPANCY_ONLY_IN: 

599 return at_home 

600 if delivery_occupancy == OCCUPANCY_ONLY_OUT: 

601 return away 

602 

603 _LOGGER.warning("SUPERNOTIFY Unknown occupancy tested: %s", delivery_occupancy) 

604 return [] 

605 

606 def _fetch_person_entity_state(self, person_id: str) -> str | None: 

607 try: 

608 tracker: State | None = self.hass_api.get_state(person_id) 

609 if tracker and isinstance(tracker.state, str): 

610 return tracker.state 

611 _LOGGER.debug("SUPERNOTIFY Unexpected state %s for %s", tracker, person_id) 

612 except Exception as e: 

613 _LOGGER.warning("SUPERNOTIFY Unable to determine occupied status for %s: %s", person_id, e) 

614 return None 

615 

616 def determine_occupancy(self) -> dict[str, list[Recipient]]: 

617 results: dict[str, list[Recipient]] = {STATE_HOME: [], STATE_NOT_HOME: []} 

618 for person_id, person_config in self.people.items(): 

619 if person_config.enabled and person_config.has_person: 

620 state: str | None = self._fetch_person_entity_state(person_id) 

621 if state in (None, STATE_HOME): 

622 # default to at home if unknown tracker 

623 results[STATE_HOME].append(person_config) 

624 else: 

625 results[STATE_NOT_HOME].append(person_config) 

626 return results 

627 

628 def mobile_devices_for_person(self, person_entity_id: str) -> list[TrackedDeviceDetails]: 

629 """Auto detect mobile_app targets for a person. 

630 

631 Targets not currently validated as async registration may not be complete at this stage 

632 

633 Args: 

634 ---- 

635 person_entity_id (str): _description_ 

636 

637 Returns: 

638 ------- 

639 list: mobile target actions for this person 

640 

641 """ 

642 person_state = self.hass_api.get_state(person_entity_id) 

643 if not person_state: 

644 _LOGGER.warning("SUPERNOTIFY Unable to resolve %s", person_entity_id) 

645 else: 

646 user_id = person_state.attributes.get(ATTR_USER_ID) 

647 if user_id: 

648 return self.mobile_devices_for_user(user_id) 

649 _LOGGER.debug("SUPERNOTIFY Unable to link %s to a user_id", person_entity_id) 

650 return [] 

651 

652 def mobile_devices_for_user(self, user_id: str) -> list[TrackedDeviceDetails]: 

653 """Auto detect mobile_app targets for a HA user, with no Person entity required.""" 

654 return self.hass_api.mobile_app_by_user_id(user_id) or []