Home Assistant Unofficial Reference 2024.12.1
account.py
Go to the documentation of this file.
1 """iCloud account."""
2 
3 from __future__ import annotations
4 
5 from datetime import timedelta
6 import logging
7 import operator
8 from typing import Any
9 
10 from pyicloud import PyiCloudService
11 from pyicloud.exceptions import (
12  PyiCloudFailedLoginException,
13  PyiCloudNoDevicesException,
14  PyiCloudServiceNotActivatedException,
15 )
16 from pyicloud.services.findmyiphone import AppleDevice
17 
18 from homeassistant.components.zone import async_active_zone
19 from homeassistant.config_entries import ConfigEntry
20 from homeassistant.const import CONF_USERNAME
21 from homeassistant.core import CALLBACK_TYPE, HomeAssistant
22 from homeassistant.exceptions import ConfigEntryNotReady
23 from homeassistant.helpers.dispatcher import dispatcher_send
24 from homeassistant.helpers.event import track_point_in_utc_time
25 from homeassistant.helpers.storage import Store
26 from homeassistant.util import slugify
27 from homeassistant.util.async_ import run_callback_threadsafe
28 from homeassistant.util.dt import utcnow
29 from homeassistant.util.location import distance
30 
31 from .const import (
32  DEVICE_BATTERY_LEVEL,
33  DEVICE_BATTERY_STATUS,
34  DEVICE_CLASS,
35  DEVICE_DISPLAY_NAME,
36  DEVICE_ID,
37  DEVICE_LOCATION,
38  DEVICE_LOCATION_HORIZONTAL_ACCURACY,
39  DEVICE_LOCATION_LATITUDE,
40  DEVICE_LOCATION_LONGITUDE,
41  DEVICE_LOST_MODE_CAPABLE,
42  DEVICE_LOW_POWER_MODE,
43  DEVICE_NAME,
44  DEVICE_PERSON_ID,
45  DEVICE_RAW_DEVICE_MODEL,
46  DEVICE_STATUS,
47  DEVICE_STATUS_CODES,
48  DEVICE_STATUS_SET,
49  DOMAIN,
50 )
51 
52 # entity attributes
53 ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval"
54 ATTR_BATTERY = "battery"
55 ATTR_BATTERY_STATUS = "battery_status"
56 ATTR_DEVICE_NAME = "device_name"
57 ATTR_DEVICE_STATUS = "device_status"
58 ATTR_LOW_POWER_MODE = "low_power_mode"
59 ATTR_OWNER_NAME = "owner_fullname"
60 
61 # services
62 SERVICE_ICLOUD_PLAY_SOUND = "play_sound"
63 SERVICE_ICLOUD_DISPLAY_MESSAGE = "display_message"
64 SERVICE_ICLOUD_LOST_DEVICE = "lost_device"
65 SERVICE_ICLOUD_UPDATE = "update"
66 ATTR_ACCOUNT = "account"
67 ATTR_LOST_DEVICE_MESSAGE = "message"
68 ATTR_LOST_DEVICE_NUMBER = "number"
69 ATTR_LOST_DEVICE_SOUND = "sound"
70 
71 _LOGGER = logging.getLogger(__name__)
72 
73 
75  """Representation of an iCloud account."""
76 
77  def __init__(
78  self,
79  hass: HomeAssistant,
80  username: str,
81  password: str,
82  icloud_dir: Store,
83  with_family: bool,
84  max_interval: int,
85  gps_accuracy_threshold: int,
86  config_entry: ConfigEntry,
87  ) -> None:
88  """Initialize an iCloud account."""
89  self.hasshass = hass
90  self._username_username = username
91  self._password_password = password
92  self._with_family_with_family = with_family
93  self._fetch_interval_fetch_interval: float = max_interval
94  self._max_interval_max_interval = max_interval
95  self._gps_accuracy_threshold_gps_accuracy_threshold = gps_accuracy_threshold
96 
97  self._icloud_dir_icloud_dir = icloud_dir
98 
99  self.apiapi: PyiCloudService | None = None
100  self._owner_fullname_owner_fullname: str | None = None
101  self._family_members_fullname_family_members_fullname: dict[str, str] = {}
102  self._devices_devices: dict[str, IcloudDevice] = {}
103  self._retried_fetch_retried_fetch = False
104  self._config_entry_config_entry = config_entry
105 
106  self.listeners: list[CALLBACK_TYPE] = []
107 
108  def setup(self) -> None:
109  """Set up an iCloud account."""
110  try:
111  self.apiapi = PyiCloudService(
112  self._username_username,
113  self._password_password,
114  self._icloud_dir_icloud_dir.path,
115  with_family=self._with_family_with_family,
116  )
117 
118  if self.apiapi.requires_2fa:
119  # Trigger a new log in to ensure the user enters the 2FA code again.
120  raise PyiCloudFailedLoginException # noqa: TRY301
121 
122  except PyiCloudFailedLoginException:
123  self.apiapi = None
124  # Login failed which means credentials need to be updated.
125  _LOGGER.error(
126  (
127  "Your password for '%s' is no longer working; Go to the "
128  "Integrations menu and click on Configure on the discovered Apple "
129  "iCloud card to login again"
130  ),
131  self._config_entry_config_entry.data[CONF_USERNAME],
132  )
133 
134  self._require_reauth_require_reauth()
135  return
136 
137  try:
138  api_devices = self.apiapi.devices
139  # Gets device owners infos
140  user_info = api_devices.response["userInfo"]
141  except (
142  PyiCloudServiceNotActivatedException,
143  PyiCloudNoDevicesException,
144  ) as err:
145  _LOGGER.error("No iCloud device found")
146  raise ConfigEntryNotReady from err
147 
148  self._owner_fullname_owner_fullname = f"{user_info['firstName']} {user_info['lastName']}"
149 
150  self._family_members_fullname_family_members_fullname = {}
151  if user_info.get("membersInfo") is not None:
152  for prs_id, member in user_info["membersInfo"].items():
153  self._family_members_fullname_family_members_fullname[prs_id] = (
154  f"{member['firstName']} {member['lastName']}"
155  )
156 
157  self._devices_devices = {}
158  self.update_devicesupdate_devices()
159 
160  def update_devices(self) -> None:
161  """Update iCloud devices."""
162  if self.apiapi is None:
163  return
164  _LOGGER.debug("Updating devices")
165 
166  if self.apiapi.requires_2fa:
167  self._require_reauth_require_reauth()
168  return
169 
170  api_devices = {}
171  try:
172  api_devices = self.apiapi.devices
173  except Exception as err: # noqa: BLE001
174  _LOGGER.error("Unknown iCloud error: %s", err)
175  self._fetch_interval_fetch_interval = 2
176  dispatcher_send(self.hasshass, self.signal_device_updatesignal_device_update)
177  self._schedule_next_fetch_schedule_next_fetch()
178  return
179 
180  # Gets devices infos
181  new_device = False
182  for device in api_devices:
183  status = device.status(DEVICE_STATUS_SET)
184  device_id = status[DEVICE_ID]
185  device_name = status[DEVICE_NAME]
186 
187  if (
188  status[DEVICE_BATTERY_STATUS] == "Unknown"
189  or status.get(DEVICE_BATTERY_LEVEL) is None
190  ):
191  continue
192 
193  if self._devices_devices.get(device_id) is not None:
194  # Seen device -> updating
195  _LOGGER.debug("Updating iCloud device: %s", device_name)
196  self._devices_devices[device_id].update(status)
197  else:
198  # New device, should be unique
199  _LOGGER.debug(
200  "Adding iCloud device: %s [model: %s]",
201  device_name,
202  status[DEVICE_RAW_DEVICE_MODEL],
203  )
204  self._devices_devices[device_id] = IcloudDevice(self, device, status)
205  self._devices_devices[device_id].update(status)
206  new_device = True
207 
208  if (
209  DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending"
210  and not self._retried_fetch_retried_fetch
211  ):
212  _LOGGER.debug("Pending devices, trying again in 15s")
213  self._fetch_interval_fetch_interval = 0.25
214  self._retried_fetch_retried_fetch = True
215  else:
216  self._fetch_interval_fetch_interval = self._determine_interval_determine_interval()
217  self._retried_fetch_retried_fetch = False
218 
219  dispatcher_send(self.hasshass, self.signal_device_updatesignal_device_update)
220  if new_device:
221  dispatcher_send(self.hasshass, self.signal_device_newsignal_device_new)
222 
223  self._schedule_next_fetch_schedule_next_fetch()
224 
225  def _require_reauth(self):
226  """Require the user to log in again."""
227  self.hasshass.add_job(self._config_entry_config_entry.async_start_reauth, self.hasshass)
228 
229  def _determine_interval(self) -> int:
230  """Calculate new interval between two API fetch (in minutes)."""
231  intervals = {"default": self._max_interval_max_interval}
232  for device in self._devices_devices.values():
233  # Max interval if no location
234  if device.location is None:
235  continue
236 
237  current_zone = run_callback_threadsafe(
238  self.hasshass.loop,
239  async_active_zone,
240  self.hasshass,
241  device.location[DEVICE_LOCATION_LATITUDE],
242  device.location[DEVICE_LOCATION_LONGITUDE],
243  device.location[DEVICE_LOCATION_HORIZONTAL_ACCURACY],
244  ).result()
245 
246  # Max interval if in zone
247  if current_zone is not None:
248  continue
249 
250  zones = (
251  self.hasshass.states.get(entity_id)
252  for entity_id in sorted(self.hasshass.states.entity_ids("zone"))
253  )
254 
255  distances = []
256  for zone_state in zones:
257  if zone_state is None:
258  continue
259  zone_state_lat = zone_state.attributes[DEVICE_LOCATION_LATITUDE]
260  zone_state_long = zone_state.attributes[DEVICE_LOCATION_LONGITUDE]
261  zone_distance = distance(
262  device.location[DEVICE_LOCATION_LATITUDE],
263  device.location[DEVICE_LOCATION_LONGITUDE],
264  zone_state_lat,
265  zone_state_long,
266  )
267  if zone_distance is not None:
268  distances.append(round(zone_distance / 1000, 1))
269 
270  # Max interval if no zone
271  if not distances:
272  continue
273  mindistance = min(distances)
274 
275  # Calculate out how long it would take for the device to drive
276  # to the nearest zone at 120 km/h:
277  interval = round(mindistance / 2)
278 
279  # Never poll more than once per minute
280  interval = max(interval, 1)
281 
282  if interval > 180:
283  # Three hour drive?
284  # This is far enough that they might be flying
285  interval = self._max_interval_max_interval
286 
287  if (
288  device.battery_level is not None
289  and device.battery_level <= 33
290  and mindistance > 3
291  ):
292  # Low battery - let's check half as often
293  interval = interval * 2
294 
295  intervals[device.name] = interval
296 
297  return max(
298  int(min(intervals.items(), key=operator.itemgetter(1))[1]),
299  self._max_interval_max_interval,
300  )
301 
302  def _schedule_next_fetch(self) -> None:
303  if not self._config_entry_config_entry.pref_disable_polling:
305  self.hasshass,
306  self.keep_alivekeep_alive,
307  utcnow() + timedelta(minutes=self._fetch_interval_fetch_interval),
308  )
309 
310  def keep_alive(self, now=None) -> None:
311  """Keep the API alive."""
312  if self.apiapi is None:
313  self.setupsetup()
314 
315  if self.apiapi is None:
316  return
317 
318  self.apiapi.authenticate()
319  self.update_devicesupdate_devices()
320 
321  def get_devices_with_name(self, name: str) -> list[Any]:
322  """Get devices by name."""
323  name_slug = slugify(name.replace(" ", "", 99))
324  result = [
325  device
326  for device in self.devicesdevices.values()
327  if slugify(device.name.replace(" ", "", 99)) == name_slug
328  ]
329  if not result:
330  raise ValueError(f"No device with name {name}")
331  return result
332 
333  @property
334  def username(self) -> str:
335  """Return the account username."""
336  return self._username_username
337 
338  @property
339  def owner_fullname(self) -> str | None:
340  """Return the account owner fullname."""
341  return self._owner_fullname_owner_fullname
342 
343  @property
344  def family_members_fullname(self) -> dict[str, str]:
345  """Return the account family members fullname."""
346  return self._family_members_fullname_family_members_fullname
347 
348  @property
349  def fetch_interval(self) -> float:
350  """Return the account fetch interval."""
351  return self._fetch_interval_fetch_interval
352 
353  @property
354  def devices(self) -> dict[str, Any]:
355  """Return the account devices."""
356  return self._devices_devices
357 
358  @property
359  def signal_device_new(self) -> str:
360  """Event specific per Freebox entry to signal new device."""
361  return f"{DOMAIN}-{self._username}-device-new"
362 
363  @property
364  def signal_device_update(self) -> str:
365  """Event specific per Freebox entry to signal updates in devices."""
366  return f"{DOMAIN}-{self._username}-device-update"
367 
368 
370  """Representation of a iCloud device."""
371 
372  _attr_attribution = "Data provided by Apple iCloud"
373 
374  def __init__(self, account: IcloudAccount, device: AppleDevice, status) -> None:
375  """Initialize the iCloud device."""
376  self._account_account = account
377 
378  self._device_device = device
379  self._status_status = status
380 
381  self._name_name = self._status_status[DEVICE_NAME]
382  self._device_id_device_id = self._status_status[DEVICE_ID]
383  self._device_class_device_class = self._status_status[DEVICE_CLASS]
384  self._device_model_device_model = self._status_status[DEVICE_DISPLAY_NAME]
385 
386  self._battery_level_battery_level: int | None = None
387  self._battery_status_battery_status = None
388  self._location_location = None
389 
390  self._attrs_attrs = {
391  ATTR_ACCOUNT_FETCH_INTERVAL: self._account_account.fetch_interval,
392  ATTR_DEVICE_NAME: self._device_model_device_model,
393  ATTR_DEVICE_STATUS: None,
394  }
395  if self._status_status[DEVICE_PERSON_ID]:
396  self._attrs_attrs[ATTR_OWNER_NAME] = account.family_members_fullname[
397  self._status_status[DEVICE_PERSON_ID]
398  ]
399  elif account.owner_fullname is not None:
400  self._attrs_attrs[ATTR_OWNER_NAME] = account.owner_fullname
401 
402  def update(self, status) -> None:
403  """Update the iCloud device."""
404  self._status_status = status
405 
406  self._status_status[ATTR_ACCOUNT_FETCH_INTERVAL] = self._account_account.fetch_interval
407 
408  device_status = DEVICE_STATUS_CODES.get(self._status_status[DEVICE_STATUS], "error")
409  self._attrs_attrs[ATTR_DEVICE_STATUS] = device_status
410 
411  self._battery_status_battery_status = self._status_status[DEVICE_BATTERY_STATUS]
412  self._attrs_attrs[ATTR_BATTERY_STATUS] = self._battery_status_battery_status
413  device_battery_level = self._status_status.get(DEVICE_BATTERY_LEVEL, 0)
414  if self._battery_status_battery_status != "Unknown" and device_battery_level is not None:
415  self._battery_level_battery_level = int(device_battery_level * 100)
416  self._attrs_attrs[ATTR_BATTERY] = self._battery_level_battery_level
417  self._attrs_attrs[ATTR_LOW_POWER_MODE] = self._status_status[DEVICE_LOW_POWER_MODE]
418 
419  if (
420  self._status_status[DEVICE_LOCATION]
421  and self._status_status[DEVICE_LOCATION][DEVICE_LOCATION_LATITUDE]
422  ):
423  location = self._status_status[DEVICE_LOCATION]
424  if self._location_location is None:
425  dispatcher_send(self._account_account.hass, self._account_account.signal_device_new)
426  self._location_location = location
427 
428  def play_sound(self) -> None:
429  """Play sound on the device."""
430  if self._account_account.api is None:
431  return
432 
433  self._account_account.api.authenticate()
434  _LOGGER.debug("Playing sound for %s", self.namename)
435  self.devicedevice.play_sound()
436 
437  def display_message(self, message: str, sound: bool = False) -> None:
438  """Display a message on the device."""
439  if self._account_account.api is None:
440  return
441 
442  self._account_account.api.authenticate()
443  _LOGGER.debug("Displaying message for %s", self.namename)
444  self.devicedevice.display_message("Subject not working", message, sound)
445 
446  def lost_device(self, number: str, message: str) -> None:
447  """Make the device in lost state."""
448  if self._account_account.api is None:
449  return
450 
451  self._account_account.api.authenticate()
452  if self._status_status[DEVICE_LOST_MODE_CAPABLE]:
453  _LOGGER.debug("Make device lost for %s", self.namename)
454  self.devicedevice.lost_device(number, message, None)
455  else:
456  _LOGGER.error("Cannot make device lost for %s", self.namename)
457 
458  @property
459  def unique_id(self) -> str:
460  """Return a unique ID."""
461  return self._device_id_device_id
462 
463  @property
464  def name(self) -> str:
465  """Return the Apple device name."""
466  return self._name_name
467 
468  @property
469  def device(self) -> AppleDevice:
470  """Return the Apple device."""
471  return self._device_device
472 
473  @property
474  def device_class(self) -> str:
475  """Return the Apple device class."""
476  return self._device_class_device_class
477 
478  @property
479  def device_model(self) -> str:
480  """Return the Apple device model."""
481  return self._device_model_device_model
482 
483  @property
484  def battery_level(self) -> int | None:
485  """Return the Apple device battery level."""
486  return self._battery_level_battery_level
487 
488  @property
489  def battery_status(self) -> str | None:
490  """Return the Apple device battery status."""
491  return self._battery_status_battery_status
492 
493  @property
494  def location(self) -> dict[str, Any] | None:
495  """Return the Apple device location."""
496  return self._location_location
497 
498  @property
499  def extra_state_attributes(self) -> dict[str, Any]:
500  """Return the attributes."""
501  return self._attrs_attrs
None __init__(self, HomeAssistant hass, str username, str password, Store icloud_dir, bool with_family, int max_interval, int gps_accuracy_threshold, ConfigEntry config_entry)
Definition: account.py:87
None display_message(self, str message, bool sound=False)
Definition: account.py:437
None lost_device(self, str number, str message)
Definition: account.py:446
None __init__(self, IcloudAccount account, AppleDevice device, status)
Definition: account.py:374
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
def authenticate(HomeAssistant hass, host, port, servers)
Definition: config_flow.py:104
None dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:137
def distance(hass, *args)
Definition: template.py:1796