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

277 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 14:29 +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) 

53from .model import DeliveryCustomization, NotifyEntityPlatform, Target 

54 

55if TYPE_CHECKING: 

56 from homeassistant.core import Context, State 

57 

58 from .binary_sensor import SupernotifyRecipientBinarySensor 

59 from .hass_api import HomeAssistantAPI, TrackedDeviceDetails 

60 

61 

62_LOGGER = logging.getLogger(__name__) 

63 

64 

65class RecipientNotifyEntity(NotifyEntity): 

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

67 

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

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

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

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

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

73 ever passed in here - no data/target. 

74 """ 

75 

76 _attr_has_entity_name = True 

77 _attr_translation_key = "recipient" 

78 

79 def __init__( 

80 self, 

81 unique_id: str, 

82 recipient: Recipient, 

83 engine: NotifyEntityPlatform, 

84 ) -> None: 

85 """Initialize the recipient notify entity.""" 

86 self._attr_unique_id = unique_id 

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

88 self._attr_supported_features = NotifyEntityFeature.TITLE 

89 self._recipient = recipient 

90 self._engine = engine 

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

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

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

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

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

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

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

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

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

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

101 # once that lane is gone. 

102 self._last_notified: str | None = None 

103 

104 @property 

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

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

107 if hasattr(self, "_async_record_notification"): 

108 return None 

109 return {"last_notified": self._last_notified} 

110 

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

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

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

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

115 

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

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

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

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

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

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

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

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

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

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

126 be reflected here at all. 

127 

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

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

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

131 if context is not None: 

132 self.async_set_context(context) 

133 if hasattr(self, "_async_record_notification"): 

134 self._async_record_notification() 

135 else: 

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

137 self.async_write_ha_state() 

138 

139 async def async_added_to_hass(self) -> None: 

140 await super().async_added_to_hass() 

141 self._recipient.notify_entity_id = self.entity_id 

142 self._recipient.notify_entity = self 

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

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

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

146 # here, same as before the refactor. 

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

148 last_state = await self.async_get_last_state() 

149 if last_state is not None: 

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

151 

152 async def async_will_remove_from_hass(self) -> None: 

153 self._recipient.notify_entity_id = None 

154 self._recipient.notify_entity = None 

155 await super().async_will_remove_from_hass() 

156 

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

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

159 await self._engine.async_send_message(message, title=title, target=self.entity_id, context=self._context) 

160 

161 

162class Recipient: 

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

164 

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

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

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

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

169 """ 

170 

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

172 config = config or {} 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

189 if self.entity_id: 

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

191 else: 

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

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

194 self.notify_entity_id: str | None = None 

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

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

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

198 self.notify_entity: RecipientNotifyEntity | None = None 

199 

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

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

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

203 } 

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

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

206 self.config_enabled: bool = self.enabled 

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

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

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

210 } 

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

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

213 

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

215 

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

217 if self.email: 

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

219 if self.phone_number: 

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

221 if self.mobile_discovery: 

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

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

224 discovered_devices: list[TrackedDeviceDetails] = ( 

225 people_registry.mobile_devices_for_user(self.user_id) 

226 if self.user_id 

227 else people_registry.mobile_devices_for_person(self.entity_id) 

228 ) 

229 if discovered_devices: 

230 new_ids = [] 

231 for d in discovered_devices: 

232 if d.mobile_app_id in self.mobile_devices: 

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

234 merged = d.as_dict() 

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

236 self.mobile_devices[d.mobile_app_id] = merged 

237 new_ids.append(d.mobile_app_id) 

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

239 elif d.mobile_app_id is not None: 

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

241 new_ids.append(d.mobile_app_id) 

242 _LOGGER.info( 

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

244 self.entity_id, 

245 ",".join(new_ids), 

246 ) 

247 else: 

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

249 if self.mobile_devices: 

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

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

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

253 if attrs: 

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

255 self.user_id = attrs.get(ATTR_USER_ID) 

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

257 self.alias = attrs.get(ATTR_ALIAS) 

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

259 self.alias = attrs.get(ATTR_FRIENDLY_NAME) 

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

261 else: 

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

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

264 

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

266 # Record that a notification has occurred for this person 

267 if self.notify_entity is not None: 

268 self.notify_entity.record_notification(context) 

269 

270 @property 

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

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

273 

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

275 """Explicitly overriding enabled state""" 

276 return [ 

277 delname 

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

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

280 ] 

281 

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

283 """Explicitly overriding enabled state""" 

284 return [ 

285 delname 

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

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

288 ] 

289 

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

291 recipient_target: Target = self._target 

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

293 if personal_delivery and personal_delivery.enabled is not False: 

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

295 recipient_target += personal_delivery.target 

296 if personal_delivery.data: 

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

298 return recipient_target 

299 

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

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

302 if not occupancy_only: 

303 result.update({ 

304 CONF_ALIAS: self.alias, 

305 CONF_EMAIL: self.email, 

306 CONF_PHONE_NUMBER: self.phone_number, 

307 ATTR_USER_ID: self.user_id, 

308 CONF_MOBILE_DISCOVERY: self.mobile_discovery, 

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

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

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

312 if self.delivery_overrides 

313 else None, 

314 }) 

315 if redact: 

316 for k in (CONF_EMAIL, CONF_PHONE_NUMBER): 

317 if k in result: 

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

319 return result 

320 

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

322 """For exposure as entity state""" 

323 attrs: dict[str, Any] = { 

324 ATTR_ENTITY_ID: self.entity_id, 

325 ATTR_ENABLED: self.enabled, 

326 CONF_EMAIL: self.email, 

327 CONF_PHONE_NUMBER: self.phone_number, 

328 ATTR_USER_ID: self.user_id, 

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

330 CONF_MOBILE_DISCOVERY: self.mobile_discovery, 

331 CONF_TARGET: self._target, 

332 CONF_DELIVERY: self.delivery_overrides, 

333 } 

334 if self.alias: 

335 attrs[ATTR_FRIENDLY_NAME] = self.alias 

336 return attrs 

337 

338 

339class PeopleRegistry: 

340 def __init__( 

341 self, 

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

343 hass_api: HomeAssistantAPI, 

344 discover: bool = False, 

345 mobile_discovery: bool = True, 

346 ) -> None: 

347 self.hass_api = hass_api 

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

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

350 self.mobile_discovery = mobile_discovery 

351 self.discover = discover 

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

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

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

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

356 

357 async def initialize(self) -> None: 

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

359 if self.discover: 

360 entity_ids = self.find_people() 

361 person_user_ids: set[str] = set() 

362 if entity_ids: 

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

364 for entity_id in entity_ids: 

365 attrs = self.person_attributes(entity_id) 

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

367 person_user_ids.add(attrs[ATTR_USER_ID]) 

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

369 

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

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

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

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

374 real_user_ids = await self.hass_api.async_real_user_ids() 

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

376 if user_only_ids: 

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

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

379 

380 for r in self._recipients: 

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

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

383 key = r.get(CONF_PERSON) or r.get(CONF_USER_ID) 

384 if not key: 

385 _LOGGER.warning("SUPERNOTIFY Skipping invalid recipient with neither 'person' nor 'user_id' key:%s", r) 

386 continue 

387 if key in recipients: 

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

389 recipients[key].update(r) 

390 else: 

391 recipients[key] = r 

392 

393 for r in recipients.values(): 

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

395 recipient.initialize(self) 

396 

397 self.people[recipient.entity_id] = recipient 

398 

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

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

401 self._entities[name] = entity 

402 

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

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

405 self._entities.pop(name, None) 

406 

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

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

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

410 

411 @callback 

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

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

414 entity = self._entities.get(name) 

415 if entity is not None: 

416 entity.async_write_ha_state() 

417 

418 def expose_notify_entities( 

419 self, entry_id: str, async_add_entities: AddConfigEntryEntitiesCallback, service: NotifyEntityPlatform 

420 ) -> None: 

421 async_add_entities( 

422 RecipientNotifyEntity(f"{entry_id}_recipient_{recipient.name}", recipient, service) 

423 for recipient in self.people.values() 

424 ) 

425 

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

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

428 if state is not None and state.attributes: 

429 return state.attributes 

430 return None 

431 

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

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

434 if recipient.user_id == user_id: 

435 return recipient.alias or recipient.name 

436 return None 

437 

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

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

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

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

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

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

444 for recipient in self.people.values() 

445 } 

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

447 return exact 

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

449 

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

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

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

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

454 

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

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

457 if user_id and recipient.user_id == user_id: 

458 return recipient.entity_id 

459 return None 

460 

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

462 return self.hass_api.entity_ids_for_domain(PERSON_DOMAIN) 

463 

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

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

466 

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

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

469 

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

471 if delivery_occupancy == OCCUPANCY_NONE: 

472 return [] 

473 

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

475 if delivery_occupancy == OCCUPANCY_ALL: 

476 return people 

477 

478 occupancy = self.determine_occupancy() 

479 

480 away = occupancy[STATE_NOT_HOME] 

481 at_home = occupancy[STATE_HOME] 

482 if delivery_occupancy == OCCUPANCY_ALL_IN: 

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

484 if delivery_occupancy == OCCUPANCY_ALL_OUT: 

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

486 if delivery_occupancy == OCCUPANCY_ANY_IN: 

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

488 if delivery_occupancy == OCCUPANCY_ANY_OUT: 

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

490 if delivery_occupancy == OCCUPANCY_ONLY_IN: 

491 return at_home 

492 if delivery_occupancy == OCCUPANCY_ONLY_OUT: 

493 return away 

494 

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

496 return [] 

497 

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

499 try: 

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

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

502 return tracker.state 

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

504 except Exception as e: 

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

506 return None 

507 

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

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

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

511 if person_config.enabled and person_config.has_person: 

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

513 if state in (None, STATE_HOME): 

514 # default to at home if unknown tracker 

515 results[STATE_HOME].append(person_config) 

516 else: 

517 results[STATE_NOT_HOME].append(person_config) 

518 return results 

519 

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

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

522 

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

524 

525 Args: 

526 ---- 

527 person_entity_id (str): _description_ 

528 

529 Returns: 

530 ------- 

531 list: mobile target actions for this person 

532 

533 """ 

534 person_state = self.hass_api.get_state(person_entity_id) 

535 if not person_state: 

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

537 else: 

538 user_id = person_state.attributes.get(ATTR_USER_ID) 

539 if user_id: 

540 return self.mobile_devices_for_user(user_id) 

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

542 return [] 

543 

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

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

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