Home Assistant Unofficial Reference 2024.12.1
legacy.py
Go to the documentation of this file.
1 """Legacy device tracker classes."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable, Coroutine, Sequence
7 from datetime import datetime, timedelta
8 import hashlib
9 from types import ModuleType
10 from typing import Any, Final, Protocol, final
11 
12 import attr
13 from propcache import cached_property
14 import voluptuous as vol
15 
16 from homeassistant import util
17 from homeassistant.components import zone
18 from homeassistant.components.zone import ENTITY_ID_HOME
19 from homeassistant.config import (
20  async_log_schema_error,
21  config_per_platform,
22  load_yaml_config_file,
23 )
24 from homeassistant.const import (
25  ATTR_ENTITY_ID,
26  ATTR_GPS_ACCURACY,
27  ATTR_ICON,
28  ATTR_LATITUDE,
29  ATTR_LONGITUDE,
30  ATTR_NAME,
31  CONF_ICON,
32  CONF_MAC,
33  CONF_NAME,
34  DEVICE_DEFAULT_NAME,
35  EVENT_HOMEASSISTANT_STOP,
36  STATE_HOME,
37  STATE_NOT_HOME,
38 )
39 from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
40 from homeassistant.exceptions import HomeAssistantError
41 from homeassistant.helpers import (
42  config_validation as cv,
43  discovery,
44  entity_registry as er,
45 )
46 from homeassistant.helpers.event import (
47  async_track_time_interval,
48  async_track_utc_time_change,
49 )
50 from homeassistant.helpers.restore_state import RestoreEntity
51 from homeassistant.helpers.typing import ConfigType, GPSType, StateType
52 from homeassistant.setup import (
53  SetupPhases,
54  async_notify_setup_error,
55  async_prepare_setup_platform,
56  async_start_setup,
57 )
58 from homeassistant.util import dt as dt_util
59 from homeassistant.util.async_ import create_eager_task
60 from homeassistant.util.yaml import dump
61 
62 from .const import (
63  ATTR_ATTRIBUTES,
64  ATTR_BATTERY,
65  ATTR_CONSIDER_HOME,
66  ATTR_DEV_ID,
67  ATTR_GPS,
68  ATTR_HOST_NAME,
69  ATTR_LOCATION_NAME,
70  ATTR_MAC,
71  ATTR_SOURCE_TYPE,
72  CONF_CONSIDER_HOME,
73  CONF_NEW_DEVICE_DEFAULTS,
74  CONF_SCAN_INTERVAL,
75  CONF_TRACK_NEW,
76  DEFAULT_CONSIDER_HOME,
77  DEFAULT_TRACK_NEW,
78  DOMAIN,
79  LOGGER,
80  PLATFORM_TYPE_LEGACY,
81  SCAN_INTERVAL,
82  SourceType,
83 )
84 
85 SERVICE_SEE: Final = "see"
86 
87 SOURCE_TYPES = [cls.value for cls in SourceType]
88 
89 NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(
90  None,
91  vol.Schema({vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean}),
92 )
93 PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA.extend(
94  {
95  vol.Optional(CONF_SCAN_INTERVAL): cv.time_period,
96  vol.Optional(CONF_TRACK_NEW): cv.boolean,
97  vol.Optional(CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME): vol.All(
98  cv.time_period, cv.positive_timedelta
99  ),
100  vol.Optional(CONF_NEW_DEVICE_DEFAULTS, default={}): NEW_DEVICE_DEFAULTS_SCHEMA,
101  }
102 )
103 PLATFORM_SCHEMA_BASE: Final[vol.Schema] = cv.PLATFORM_SCHEMA_BASE.extend(
104  PLATFORM_SCHEMA.schema
105 )
106 
107 SERVICE_SEE_PAYLOAD_SCHEMA: Final[vol.Schema] = vol.Schema(
108  vol.All(
109  cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID),
110  {
111  ATTR_MAC: cv.string,
112  ATTR_DEV_ID: cv.string,
113  ATTR_HOST_NAME: cv.string,
114  ATTR_LOCATION_NAME: cv.string,
115  ATTR_GPS: cv.gps,
116  ATTR_GPS_ACCURACY: cv.positive_int,
117  ATTR_BATTERY: cv.positive_int,
118  ATTR_ATTRIBUTES: dict,
119  ATTR_SOURCE_TYPE: vol.Coerce(SourceType),
120  ATTR_CONSIDER_HOME: cv.time_period,
121  # Temp workaround for iOS app introduced in 0.65
122  vol.Optional("battery_status"): str,
123  vol.Optional("hostname"): str,
124  },
125  )
126 )
127 
128 YAML_DEVICES: Final = "known_devices.yaml"
129 EVENT_NEW_DEVICE: Final = "device_tracker_new_device"
130 
131 
132 class SeeCallback(Protocol):
133  """Protocol type for DeviceTracker.see callback."""
134 
135  def __call__(
136  self,
137  mac: str | None = None,
138  dev_id: str | None = None,
139  host_name: str | None = None,
140  location_name: str | None = None,
141  gps: GPSType | None = None,
142  gps_accuracy: int | None = None,
143  battery: int | None = None,
144  attributes: dict[str, Any] | None = None,
145  source_type: SourceType | str = SourceType.GPS,
146  picture: str | None = None,
147  icon: str | None = None,
148  consider_home: timedelta | None = None,
149  ) -> None:
150  """Define see type."""
151 
152 
153 class AsyncSeeCallback(Protocol):
154  """Protocol type for DeviceTracker.async_see callback."""
155 
156  async def __call__(
157  self,
158  mac: str | None = None,
159  dev_id: str | None = None,
160  host_name: str | None = None,
161  location_name: str | None = None,
162  gps: GPSType | None = None,
163  gps_accuracy: int | None = None,
164  battery: int | None = None,
165  attributes: dict[str, Any] | None = None,
166  source_type: SourceType | str = SourceType.GPS,
167  picture: str | None = None,
168  icon: str | None = None,
169  consider_home: timedelta | None = None,
170  ) -> None:
171  """Define async_see type."""
172 
173 
174 def see(
175  hass: HomeAssistant,
176  mac: str | None = None,
177  dev_id: str | None = None,
178  host_name: str | None = None,
179  location_name: str | None = None,
180  gps: GPSType | None = None,
181  gps_accuracy: int | None = None,
182  battery: int | None = None,
183  attributes: dict[str, Any] | None = None,
184 ) -> None:
185  """Call service to notify you see device."""
186  data: dict[str, Any] = {
187  key: value
188  for key, value in (
189  (ATTR_MAC, mac),
190  (ATTR_DEV_ID, dev_id),
191  (ATTR_HOST_NAME, host_name),
192  (ATTR_LOCATION_NAME, location_name),
193  (ATTR_GPS, gps),
194  (ATTR_GPS_ACCURACY, gps_accuracy),
195  (ATTR_BATTERY, battery),
196  )
197  if value is not None
198  }
199  if attributes is not None:
200  data[ATTR_ATTRIBUTES] = attributes
201  hass.services.call(DOMAIN, SERVICE_SEE, data)
202 
203 
204 @callback
205 def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> None:
206  """Set up the legacy integration."""
207  # The tracker is loaded in the _async_setup_integration task so
208  # we create a future to avoid waiting on it here so that only
209  # async_platform_discovered will have to wait in the rare event
210  # a custom component still uses the legacy device tracker discovery.
211  tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future()
212 
213  async def async_platform_discovered(
214  p_type: str, info: dict[str, Any] | None
215  ) -> None:
216  """Load a platform."""
217  platform = await async_create_platform_type(hass, config, p_type, {})
218 
219  if platform is None or platform.type != PLATFORM_TYPE_LEGACY:
220  return
221 
222  tracker = await tracker_future
223  await platform.async_setup_legacy(hass, tracker, info)
224 
225  discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
226  #
227  # Legacy and platforms load in a non-awaited tracked task
228  # to ensure device tracker setup can continue and config
229  # entry integrations are not waiting for legacy device
230  # tracker platforms to be set up.
231  #
232  hass.async_create_task(
233  _async_setup_integration(hass, config, tracker_future), eager_start=True
234  )
235 
236 
238  hass: HomeAssistant,
239  config: ConfigType,
240  tracker_future: asyncio.Future[DeviceTracker],
241 ) -> None:
242  """Set up the legacy integration."""
243  tracker = await get_tracker(hass, config)
244  tracker_future.set_result(tracker)
245 
246  async def async_see_service(call: ServiceCall) -> None:
247  """Service to see a device."""
248  # Temp workaround for iOS, introduced in 0.65
249  data = dict(call.data)
250  data.pop("hostname", None)
251  data.pop("battery_status", None)
252  await tracker.async_see(**data)
253 
254  hass.services.async_register(
255  DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA
256  )
257 
258  legacy_platforms = await async_extract_config(hass, config)
259 
260  setup_tasks = [
261  create_eager_task(legacy_platform.async_setup_legacy(hass, tracker))
262  for legacy_platform in legacy_platforms
263  ]
264 
265  if setup_tasks:
266  await asyncio.wait(setup_tasks)
267 
268  # Clean up stale devices
269  cancel_update_stale = async_track_utc_time_change(
270  hass, tracker.async_update_stale, second=range(0, 60, 5)
271  )
272 
273  # restore
274  await tracker.async_setup_tracked_device()
275 
276  @callback
277  def _on_hass_stop(_: Event) -> None:
278  """Cleanup when Home Assistant stops.
279 
280  Cancel the async_update_stale schedule.
281  """
282  cancel_update_stale()
283 
284  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop)
285 
286 
287 @attr.s
289  """Class to hold platform information."""
290 
291  LEGACY_SETUP: Final[tuple[str, ...]] = (
292  "async_get_scanner",
293  "get_scanner",
294  "async_setup_scanner",
295  "setup_scanner",
296  )
297 
298  name: str = attr.ib()
299  platform: ModuleType = attr.ib()
300  config: dict = attr.ib()
301 
302  @cached_property
303  def type(self) -> str | None:
304  """Return platform type."""
305  methods, platform_type = self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY
306  for method in methods:
307  if hasattr(self.platform, method):
308  return platform_type
309  return None
310 
312  self,
313  hass: HomeAssistant,
314  tracker: DeviceTracker,
315  discovery_info: dict[str, Any] | None = None,
316  ) -> None:
317  """Set up a legacy platform."""
318  assert self.typetypetype == PLATFORM_TYPE_LEGACY
319  full_name = f"{self.name}.{DOMAIN}"
320  LOGGER.info("Setting up %s", full_name)
321  with async_start_setup(
322  hass,
323  integration=self.name,
324  group=str(id(self.config)),
325  phase=SetupPhases.PLATFORM_SETUP,
326  ):
327  try:
328  scanner = None
329  setup: bool | None = None
330  if hasattr(self.platform, "async_get_scanner"):
331  scanner = await self.platform.async_get_scanner(
332  hass, {DOMAIN: self.config}
333  )
334  elif hasattr(self.platform, "get_scanner"):
335  scanner = await hass.async_add_executor_job(
336  self.platform.get_scanner,
337  hass,
338  {DOMAIN: self.config},
339  )
340  elif hasattr(self.platform, "async_setup_scanner"):
341  setup = await self.platform.async_setup_scanner(
342  hass, self.config, tracker.async_see, discovery_info
343  )
344  elif hasattr(self.platform, "setup_scanner"):
345  setup = await hass.async_add_executor_job(
346  self.platform.setup_scanner,
347  hass,
348  self.config,
349  tracker.see,
350  discovery_info,
351  )
352  else:
353  raise HomeAssistantError("Invalid legacy device_tracker platform.") # noqa: TRY301
354 
355  if scanner is not None:
357  hass, self.config, scanner, tracker.async_see, self.typetypetype
358  )
359 
360  if not setup and scanner is None:
361  LOGGER.error(
362  "Error setting up platform %s %s", self.typetypetype, self.name
363  )
364  return
365 
366  hass.config.components.add(full_name)
367 
368  except Exception: # noqa: BLE001
369  LOGGER.exception(
370  "Error setting up platform %s %s", self.typetypetype, self.name
371  )
372 
373 
375  hass: HomeAssistant, config: ConfigType
376 ) -> list[DeviceTrackerPlatform]:
377  """Extract device tracker config and split between legacy and modern."""
378  legacy: list[DeviceTrackerPlatform] = []
379 
380  for platform in await asyncio.gather(
381  *(
382  async_create_platform_type(hass, config, p_type, p_config)
383  for p_type, p_config in config_per_platform(config, DOMAIN)
384  if p_type is not None
385  )
386  ):
387  if platform is None:
388  continue
389 
390  if platform.type == PLATFORM_TYPE_LEGACY:
391  legacy.append(platform)
392  else:
393  raise ValueError(
394  f"Unable to determine type for {platform.name}: {platform.type}"
395  )
396 
397  return legacy
398 
399 
401  hass: HomeAssistant, config: ConfigType, p_type: str, p_config: dict
402 ) -> DeviceTrackerPlatform | None:
403  """Determine type of platform."""
404  platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type)
405 
406  if platform is None:
407  return None
408 
409  return DeviceTrackerPlatform(p_type, platform, p_config)
410 
411 
413  scanner: DeviceScanner,
414  device_name_uses_executor: bool,
415  extra_attributes_uses_executor: bool,
416  seen: set[str],
417  found_devices: list[str],
418 ) -> tuple[dict[str, str | None], dict[str, dict[str, Any]]]:
419  """Load device names and attributes in a single executor job."""
420  host_name_by_mac: dict[str, str | None] = {}
421  extra_attributes_by_mac: dict[str, dict[str, Any]] = {}
422  for mac in found_devices:
423  if device_name_uses_executor and mac not in seen:
424  host_name_by_mac[mac] = scanner.get_device_name(mac)
425  if extra_attributes_uses_executor:
426  try:
427  extra_attributes_by_mac[mac] = scanner.get_extra_attributes(mac)
428  except NotImplementedError:
429  extra_attributes_by_mac[mac] = {}
430  return host_name_by_mac, extra_attributes_by_mac
431 
432 
433 @callback
435  hass: HomeAssistant,
436  config: ConfigType,
437  scanner: DeviceScanner,
438  async_see_device: Callable[..., Coroutine[None, None, None]],
439  platform: str,
440 ) -> None:
441  """Set up the connect scanner-based platform to device tracker.
442 
443  This method must be run in the event loop.
444  """
445  interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
446  update_lock = asyncio.Lock()
447  scanner.hass = hass
448 
449  # Initial scan of each mac we also tell about host name for config
450  seen: set[str] = set()
451 
452  async def async_device_tracker_scan(now: datetime | None) -> None:
453  """Handle interval matches."""
454  if update_lock.locked():
455  LOGGER.warning(
456  (
457  "Updating device list from %s took longer than the scheduled "
458  "scan interval %s"
459  ),
460  platform,
461  interval,
462  )
463  return
464 
465  async with update_lock:
466  found_devices = await scanner.async_scan_devices()
467 
468  device_name_uses_executor = (
469  scanner.async_get_device_name.__func__ # type: ignore[attr-defined]
470  is DeviceScanner.async_get_device_name
471  )
472  extra_attributes_uses_executor = (
473  scanner.async_get_extra_attributes.__func__ # type: ignore[attr-defined]
474  is DeviceScanner.async_get_extra_attributes
475  )
476  host_name_by_mac: dict[str, str | None] = {}
477  extra_attributes_by_mac: dict[str, dict[str, Any]] = {}
478  if device_name_uses_executor or extra_attributes_uses_executor:
479  (
480  host_name_by_mac,
481  extra_attributes_by_mac,
482  ) = await hass.async_add_executor_job(
483  _load_device_names_and_attributes,
484  scanner,
485  device_name_uses_executor,
486  extra_attributes_uses_executor,
487  seen,
488  found_devices,
489  )
490 
491  for mac in found_devices:
492  if mac in seen:
493  host_name = None
494  else:
495  host_name = host_name_by_mac.get(
496  mac, await scanner.async_get_device_name(mac)
497  )
498  seen.add(mac)
499 
500  try:
501  extra_attributes = extra_attributes_by_mac.get(
502  mac, await scanner.async_get_extra_attributes(mac)
503  )
504  except NotImplementedError:
505  extra_attributes = {}
506 
507  kwargs: dict[str, Any] = {
508  "mac": mac,
509  "host_name": host_name,
510  "source_type": SourceType.ROUTER,
511  "attributes": {
512  "scanner": scanner.__class__.__name__,
513  **extra_attributes,
514  },
515  }
516 
517  zone_home = hass.states.get(ENTITY_ID_HOME)
518  if zone_home is not None:
519  kwargs["gps"] = [
520  zone_home.attributes[ATTR_LATITUDE],
521  zone_home.attributes[ATTR_LONGITUDE],
522  ]
523  kwargs["gps_accuracy"] = 0
524 
525  hass.async_create_task(async_see_device(**kwargs), eager_start=True)
526 
527  cancel_legacy_scan = async_track_time_interval(
528  hass,
529  async_device_tracker_scan,
530  interval,
531  name=f"device_tracker {platform} legacy scan",
532  )
533  hass.async_create_task(async_device_tracker_scan(None), eager_start=True)
534 
535  @callback
536  def _on_hass_stop(_: Event) -> None:
537  """Cleanup when Home Assistant stops.
538 
539  Cancel the legacy scan.
540  """
541  cancel_legacy_scan()
542 
543  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop)
544 
545 
546 async def get_tracker(hass: HomeAssistant, config: ConfigType) -> DeviceTracker:
547  """Create a tracker."""
548  yaml_path = hass.config.path(YAML_DEVICES)
549 
550  conf = config.get(DOMAIN, [])
551  conf = conf[0] if conf else {}
552  consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME)
553 
554  defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {})
555  if (track_new := conf.get(CONF_TRACK_NEW)) is None:
556  track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW)
557 
558  devices = await async_load_config(yaml_path, hass, consider_home)
559  return DeviceTracker(hass, consider_home, track_new, defaults, devices)
560 
561 
563  """Representation of a device tracker."""
564 
565  def __init__(
566  self,
567  hass: HomeAssistant,
568  consider_home: timedelta,
569  track_new: bool,
570  defaults: dict[str, Any],
571  devices: Sequence[Device],
572  ) -> None:
573  """Initialize a device tracker."""
574  self.hasshass = hass
575  self.devices: dict[str, Device] = {dev.dev_id: dev for dev in devices}
576  self.mac_to_devmac_to_dev = {dev.mac: dev for dev in devices if dev.mac}
577  self.consider_homeconsider_home = consider_home
578  self.track_newtrack_new = (
579  track_new
580  if track_new is not None
581  else defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW)
582  )
583  self.defaultsdefaults = defaults
584  self._is_updating_is_updating = asyncio.Lock()
585 
586  for dev in devices:
587  if self.devices[dev.dev_id] is not dev:
588  LOGGER.warning("Duplicate device IDs detected %s", dev.dev_id)
589  if dev.mac and self.mac_to_devmac_to_dev[dev.mac] is not dev:
590  LOGGER.warning("Duplicate device MAC addresses detected %s", dev.mac)
591 
592  def see(
593  self,
594  mac: str | None = None,
595  dev_id: str | None = None,
596  host_name: str | None = None,
597  location_name: str | None = None,
598  gps: GPSType | None = None,
599  gps_accuracy: int | None = None,
600  battery: int | None = None,
601  attributes: dict[str, Any] | None = None,
602  source_type: SourceType | str = SourceType.GPS,
603  picture: str | None = None,
604  icon: str | None = None,
605  consider_home: timedelta | None = None,
606  ) -> None:
607  """Notify the device tracker that you see a device."""
608  self.hasshass.create_task(
609  self.async_seeasync_see(
610  mac,
611  dev_id,
612  host_name,
613  location_name,
614  gps,
615  gps_accuracy,
616  battery,
617  attributes,
618  source_type,
619  picture,
620  icon,
621  consider_home,
622  )
623  )
624 
625  async def async_see(
626  self,
627  mac: str | None = None,
628  dev_id: str | None = None,
629  host_name: str | None = None,
630  location_name: str | None = None,
631  gps: GPSType | None = None,
632  gps_accuracy: int | None = None,
633  battery: int | None = None,
634  attributes: dict[str, Any] | None = None,
635  source_type: SourceType | str = SourceType.GPS,
636  picture: str | None = None,
637  icon: str | None = None,
638  consider_home: timedelta | None = None,
639  ) -> None:
640  """Notify the device tracker that you see a device.
641 
642  This method is a coroutine.
643  """
644  registry = er.async_get(self.hasshass)
645  if mac is None and dev_id is None:
646  raise HomeAssistantError("Neither mac or device id passed in")
647  if mac is not None:
648  mac = str(mac).upper()
649  if (device := self.mac_to_devmac_to_dev.get(mac)) is None:
650  dev_id = util.slugify(host_name or "") or util.slugify(mac)
651  else:
652  dev_id = cv.slug(str(dev_id).lower())
653  device = self.devices.get(dev_id)
654 
655  if device is not None:
656  await device.async_seen(
657  host_name,
658  location_name,
659  gps,
660  gps_accuracy,
661  battery,
662  attributes,
663  source_type,
664  consider_home,
665  )
666  if device.track:
667  device.async_write_ha_state()
668  return
669 
670  # If it's None then device is not None and we can't get here.
671  assert dev_id is not None
672 
673  # Guard from calling see on entity registry entities.
674  entity_id = f"{DOMAIN}.{dev_id}"
675  if registry.async_is_registered(entity_id):
676  LOGGER.error(
677  "The see service is not supported for this entity %s", entity_id
678  )
679  return
680 
681  # If no device can be found, create it
682  dev_id = util.ensure_unique_string(dev_id, self.devices.keys())
683  device = Device(
684  self.hasshass,
685  consider_home or self.consider_homeconsider_home,
686  self.track_newtrack_new,
687  dev_id,
688  mac,
689  picture=picture,
690  icon=icon,
691  )
692  self.devices[dev_id] = device
693  if mac is not None:
694  self.mac_to_devmac_to_dev[mac] = device
695 
696  await device.async_seen(
697  host_name,
698  location_name,
699  gps,
700  gps_accuracy,
701  battery,
702  attributes,
703  source_type,
704  )
705 
706  if device.track:
707  device.async_write_ha_state()
708 
709  self.hasshass.bus.async_fire(
710  EVENT_NEW_DEVICE,
711  {
712  ATTR_ENTITY_ID: device.entity_id,
713  ATTR_HOST_NAME: device.host_name,
714  ATTR_MAC: device.mac,
715  },
716  )
717 
718  # update known_devices.yaml
719  self.hasshass.async_create_task(
720  self.async_update_configasync_update_config(
721  self.hasshass.config.path(YAML_DEVICES), dev_id, device
722  ),
723  eager_start=True,
724  )
725 
726  async def async_update_config(self, path: str, dev_id: str, device: Device) -> None:
727  """Add device to YAML configuration file.
728 
729  This method is a coroutine.
730  """
731  async with self._is_updating_is_updating:
732  await self.hasshass.async_add_executor_job(
733  update_config, self.hasshass.config.path(YAML_DEVICES), dev_id, device
734  )
735 
736  @callback
737  def async_update_stale(self, now: datetime) -> None:
738  """Update stale devices.
739 
740  This method must be run in the event loop.
741  """
742  for device in self.devices.values():
743  if (device.track and device.last_update_home) and device.stale(now):
744  self.hasshass.async_create_task(
745  device.async_update_ha_state(True), eager_start=True
746  )
747 
748  async def async_setup_tracked_device(self) -> None:
749  """Set up all not exists tracked devices.
750 
751  This method is a coroutine.
752  """
753  for device in self.devices.values():
754  if device.track and not device.last_seen:
755  # async_added_to_hass is unlikely to suspend so
756  # do not gather here to avoid unnecessary overhead
757  # of creating a task per device.
758  #
759  # We used to have the overhead of potentially loading
760  # restore state for each device here, but RestoreState
761  # is always loaded ahead of time now.
762  await device.async_added_to_hass()
763  device.async_write_ha_state()
764 
765 
767  """Base class for a tracked device."""
768 
769  # This entity is legacy and does not have a platform.
770  # We can't fix this easily without breaking changes.
771  _no_platform_reported = True
772 
773  host_name: str | None = None
774  location_name: str | None = None
775  gps: GPSType | None = None
776  gps_accuracy: int = 0
777  last_seen: datetime | None = None
778  battery: int | None = None
779  attributes: dict | None = None
780 
781  # Track if the last update of this device was HOME.
782  last_update_home: bool = False
783  _state: str = STATE_NOT_HOME
784 
785  def __init__(
786  self,
787  hass: HomeAssistant,
788  consider_home: timedelta,
789  track: bool,
790  dev_id: str,
791  mac: str | None,
792  name: str | None = None,
793  picture: str | None = None,
794  gravatar: str | None = None,
795  icon: str | None = None,
796  ) -> None:
797  """Initialize a device."""
798  self.hasshasshass = hass
799  self.entity_identity_identity_id = f"{DOMAIN}.{dev_id}"
800 
801  # Timedelta object how long we consider a device home if it is not
802  # detected anymore.
803  self.consider_homeconsider_home = consider_home
804 
805  # Device ID
806  self.dev_iddev_id = dev_id
807  self.macmac = mac
808 
809  # If we should track this device
810  self.tracktrack = track
811 
812  # Configured name
813  self.config_nameconfig_name = name
814 
815  # Configured picture
816  self.config_pictureconfig_picture: str | None
817  if gravatar is not None:
818  self.config_pictureconfig_picture = get_gravatar_for_email(gravatar)
819  else:
820  self.config_pictureconfig_picture = picture
821 
822  self._icon_icon = icon
823 
824  self.source_typesource_type: SourceType | str | None = None
825 
826  self._attributes: dict[str, Any] = {}
827 
828  @property
829  def name(self) -> str:
830  """Return the name of the entity."""
831  return self.config_nameconfig_name or self.host_namehost_name or self.dev_iddev_id or DEVICE_DEFAULT_NAME
832 
833  @property
834  def state(self) -> str:
835  """Return the state of the device."""
836  return self._state_state
837 
838  @property
839  def entity_picture(self) -> str | None:
840  """Return the picture of the device."""
841  return self.config_pictureconfig_picture
842 
843  @final
844  @property
845  def state_attributes(self) -> dict[str, StateType]:
846  """Return the device state attributes."""
847  attributes: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_typesource_type}
848 
849  if self.gpsgps is not None:
850  attributes[ATTR_LATITUDE] = self.gpsgps[0]
851  attributes[ATTR_LONGITUDE] = self.gpsgps[1]
852  attributes[ATTR_GPS_ACCURACY] = self.gps_accuracygps_accuracy
853 
854  if self.batterybattery is not None:
855  attributes[ATTR_BATTERY] = self.batterybattery
856 
857  return attributes
858 
859  @property
860  def extra_state_attributes(self) -> dict[str, Any]:
861  """Return device state attributes."""
862  return self._attributes
863 
864  @property
865  def icon(self) -> str | None:
866  """Return device icon."""
867  return self._icon_icon
868 
869  async def async_seen(
870  self,
871  host_name: str | None = None,
872  location_name: str | None = None,
873  gps: GPSType | None = None,
874  gps_accuracy: int | None = None,
875  battery: int | None = None,
876  attributes: dict[str, Any] | None = None,
877  source_type: SourceType | str = SourceType.GPS,
878  consider_home: timedelta | None = None,
879  ) -> None:
880  """Mark the device as seen."""
881  self.source_typesource_type = source_type
882  self.last_seenlast_seen = dt_util.utcnow()
883  self.host_namehost_name = host_name or self.host_namehost_name
884  self.location_namelocation_name = location_name
885  self.consider_homeconsider_home = consider_home or self.consider_homeconsider_home
886 
887  if battery is not None:
888  self.batterybattery = battery
889  if attributes is not None:
890  self._attributes.update(attributes)
891 
892  self.gpsgps = None
893 
894  if gps is not None:
895  try:
896  self.gpsgps = float(gps[0]), float(gps[1])
897  self.gps_accuracygps_accuracy = gps_accuracy or 0
898  except (ValueError, TypeError, IndexError):
899  self.gpsgps = None
900  self.gps_accuracygps_accuracy = 0
901  LOGGER.warning("Could not parse gps value for %s: %s", self.dev_iddev_id, gps)
902 
903  await self.async_updateasync_update()
904 
905  def stale(self, now: datetime | None = None) -> bool:
906  """Return if device state is stale.
907 
908  Async friendly.
909  """
910  return (
911  self.last_seenlast_seen is None
912  or (now or dt_util.utcnow()) - self.last_seenlast_seen > self.consider_homeconsider_home
913  )
914 
915  def mark_stale(self) -> None:
916  """Mark the device state as stale."""
917  self._state_state = STATE_NOT_HOME
918  self.gpsgps = None
919  self.last_update_homelast_update_home = False
920 
921  async def async_update(self) -> None:
922  """Update state of entity.
923 
924  This method is a coroutine.
925  """
926  if not self.last_seenlast_seen:
927  return
928  if self.location_namelocation_name:
929  self._state_state = self.location_namelocation_name
930  elif self.gpsgps is not None and self.source_typesource_type == SourceType.GPS:
931  zone_state = zone.async_active_zone(
932  self.hasshasshass, self.gpsgps[0], self.gpsgps[1], self.gps_accuracygps_accuracy
933  )
934  if zone_state is None:
935  self._state_state = STATE_NOT_HOME
936  elif zone_state.entity_id == zone.ENTITY_ID_HOME:
937  self._state_state = STATE_HOME
938  else:
939  self._state_state = zone_state.name
940  elif self.stalestale():
941  self.mark_stalemark_stale()
942  else:
943  self._state_state = STATE_HOME
944  self.last_update_homelast_update_home = True
945 
946  async def async_added_to_hass(self) -> None:
947  """Add an entity."""
948  await super().async_added_to_hass()
949  if not (state := await self.async_get_last_stateasync_get_last_state()):
950  return
951  self._state_state = state.state
952  self.last_update_homelast_update_home = state.state == STATE_HOME
953  self.last_seenlast_seen = dt_util.utcnow()
954 
955  for attribute, var in (
956  (ATTR_SOURCE_TYPE, "source_type"),
957  (ATTR_GPS_ACCURACY, "gps_accuracy"),
958  (ATTR_BATTERY, "battery"),
959  ):
960  if attribute in state.attributes:
961  setattr(self, var, state.attributes[attribute])
962 
963  if ATTR_LONGITUDE in state.attributes:
964  self.gpsgps = (
965  state.attributes[ATTR_LATITUDE],
966  state.attributes[ATTR_LONGITUDE],
967  )
968 
969 
971  """Device scanner object."""
972 
973  hass: HomeAssistant | None = None
974 
975  def scan_devices(self) -> list[str]:
976  """Scan for devices."""
977  raise NotImplementedError
978 
979  async def async_scan_devices(self) -> list[str]:
980  """Scan for devices."""
981  assert (
982  self.hass is not None
983  ), "hass should be set by async_setup_scanner_platform"
984  return await self.hass.async_add_executor_job(self.scan_devicesscan_devices)
985 
986  def get_device_name(self, device: str) -> str | None:
987  """Get the name of a device."""
988  raise NotImplementedError
989 
990  async def async_get_device_name(self, device: str) -> str | None:
991  """Get the name of a device."""
992  assert (
993  self.hass is not None
994  ), "hass should be set by async_setup_scanner_platform"
995  return await self.hass.async_add_executor_job(self.get_device_nameget_device_name, device)
996 
997  def get_extra_attributes(self, device: str) -> dict:
998  """Get the extra attributes of a device."""
999  raise NotImplementedError
1000 
1001  async def async_get_extra_attributes(self, device: str) -> dict:
1002  """Get the extra attributes of a device."""
1003  assert (
1004  self.hass is not None
1005  ), "hass should be set by async_setup_scanner_platform"
1006  return await self.hass.async_add_executor_job(self.get_extra_attributesget_extra_attributes, device)
1007 
1008 
1010  path: str, hass: HomeAssistant, consider_home: timedelta
1011 ) -> list[Device]:
1012  """Load devices from YAML configuration file.
1013 
1014  This method is a coroutine.
1015  """
1016  dev_schema = vol.Schema(
1017  {
1018  vol.Required(CONF_NAME): cv.string,
1019  vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon),
1020  vol.Optional("track", default=False): cv.boolean,
1021  vol.Optional(CONF_MAC, default=None): vol.Any(
1022  None, vol.All(cv.string, vol.Upper)
1023  ),
1024  vol.Optional("gravatar", default=None): vol.Any(None, cv.string),
1025  vol.Optional("picture", default=None): vol.Any(None, cv.string),
1026  vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All(
1027  cv.time_period, cv.positive_timedelta
1028  ),
1029  }
1030  )
1031  result: list[Device] = []
1032  try:
1033  devices = await hass.async_add_executor_job(load_yaml_config_file, path)
1034  except HomeAssistantError as err:
1035  LOGGER.error("Unable to load %s: %s", path, str(err))
1036  return []
1037  except FileNotFoundError:
1038  return []
1039 
1040  for dev_id, device in devices.items():
1041  # Deprecated option. We just ignore it to avoid breaking change
1042  device.pop("vendor", None)
1043  device.pop("hide_if_away", None)
1044  try:
1045  device = dev_schema(device)
1046  device["dev_id"] = cv.slugify(dev_id)
1047  except vol.Invalid as exp:
1048  async_log_schema_error(exp, dev_id, devices, hass)
1049  async_notify_setup_error(hass, DOMAIN)
1050  else:
1051  result.append(Device(hass, **device))
1052  return result
1053 
1054 
1055 def update_config(path: str, dev_id: str, device: Device) -> None:
1056  """Add device to YAML configuration file."""
1057  with open(path, "a", encoding="utf8") as out:
1058  device_config = {
1059  device.dev_id: {
1060  ATTR_NAME: device.name,
1061  ATTR_MAC: device.mac,
1062  ATTR_ICON: device.icon,
1063  "picture": device.config_picture,
1064  "track": device.track,
1065  }
1066  }
1067  out.write("\n")
1068  out.write(dump(device_config))
1069 
1070 
1071 def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None:
1072  """Remove device from YAML configuration file."""
1073  path = hass.config.path(YAML_DEVICES)
1074  devices = load_yaml_config_file(path)
1075  devices.pop(device_id)
1076  dumped = dump(devices)
1077 
1078  with open(path, "r+", encoding="utf8") as out:
1079  out.seek(0)
1080  out.truncate()
1081  out.write(dumped)
1082 
1083 
1084 def get_gravatar_for_email(email: str) -> str:
1085  """Return an 80px Gravatar for the given email address.
1086 
1087  Async friendly.
1088  """
1089 
1090  return (
1091  "https://www.gravatar.com/avatar/"
1092  f"{hashlib.md5(email.encode('utf-8').lower()).hexdigest()}.jpg?s=80&d=wavatar"
1093  )
None __call__(self, str|None mac=None, str|None dev_id=None, str|None host_name=None, str|None location_name=None, GPSType|None gps=None, int|None gps_accuracy=None, int|None battery=None, dict[str, Any]|None attributes=None, SourceType|str source_type=SourceType.GPS, str|None picture=None, str|None icon=None, timedelta|None consider_home=None)
Definition: legacy.py:170
None async_setup_legacy(self, HomeAssistant hass, DeviceTracker tracker, dict[str, Any]|None discovery_info=None)
Definition: legacy.py:316
None async_update_config(self, str path, str dev_id, Device device)
Definition: legacy.py:726
None async_see(self, str|None mac=None, str|None dev_id=None, str|None host_name=None, str|None location_name=None, GPSType|None gps=None, int|None gps_accuracy=None, int|None battery=None, dict[str, Any]|None attributes=None, SourceType|str source_type=SourceType.GPS, str|None picture=None, str|None icon=None, timedelta|None consider_home=None)
Definition: legacy.py:639
None see(self, str|None mac=None, str|None dev_id=None, str|None host_name=None, str|None location_name=None, GPSType|None gps=None, int|None gps_accuracy=None, int|None battery=None, dict[str, Any]|None attributes=None, SourceType|str source_type=SourceType.GPS, str|None picture=None, str|None icon=None, timedelta|None consider_home=None)
Definition: legacy.py:606
None __init__(self, HomeAssistant hass, timedelta consider_home, bool track_new, dict[str, Any] defaults, Sequence[Device] devices)
Definition: legacy.py:572
None __init__(self, HomeAssistant hass, timedelta consider_home, bool track, str dev_id, str|None mac, str|None name=None, str|None picture=None, str|None gravatar=None, str|None icon=None)
Definition: legacy.py:796
bool stale(self, datetime|None now=None)
Definition: legacy.py:905
None async_seen(self, str|None host_name=None, str|None location_name=None, GPSType|None gps=None, int|None gps_accuracy=None, int|None battery=None, dict[str, Any]|None attributes=None, SourceType|str source_type=SourceType.GPS, timedelta|None consider_home=None)
Definition: legacy.py:879
None __call__(self, str|None mac=None, str|None dev_id=None, str|None host_name=None, str|None location_name=None, GPSType|None gps=None, int|None gps_accuracy=None, int|None battery=None, dict[str, Any]|None attributes=None, SourceType|str source_type=SourceType.GPS, str|None picture=None, str|None icon=None, timedelta|None consider_home=None)
Definition: legacy.py:149
ArrisDeviceScanner|None async_get_scanner(HomeAssistant hass, ConfigType config)
bool async_setup_scanner(HomeAssistant hass, ConfigType config, AsyncSeeCallback async_see, DiscoveryInfoType|None discovery_info=None)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None remove_device_from_config(HomeAssistant hass, str device_id)
Definition: legacy.py:1071
None see(HomeAssistant hass, str|None mac=None, str|None dev_id=None, str|None host_name=None, str|None location_name=None, GPSType|None gps=None, int|None gps_accuracy=None, int|None battery=None, dict[str, Any]|None attributes=None)
Definition: legacy.py:184
DeviceTrackerPlatform|None async_create_platform_type(HomeAssistant hass, ConfigType config, str p_type, dict p_config)
Definition: legacy.py:402
tuple[dict[str, str|None], dict[str, dict[str, Any]]] _load_device_names_and_attributes(DeviceScanner scanner, bool device_name_uses_executor, bool extra_attributes_uses_executor, set[str] seen, list[str] found_devices)
Definition: legacy.py:418
DeviceTracker get_tracker(HomeAssistant hass, ConfigType config)
Definition: legacy.py:546
list[DeviceTrackerPlatform] async_extract_config(HomeAssistant hass, ConfigType config)
Definition: legacy.py:376
None _async_setup_integration(HomeAssistant hass, ConfigType config, asyncio.Future[DeviceTracker] tracker_future)
Definition: legacy.py:241
None async_setup_integration(HomeAssistant hass, ConfigType config)
Definition: legacy.py:205
None update_config(str path, str dev_id, Device device)
Definition: legacy.py:1055
None async_setup_scanner_platform(HomeAssistant hass, ConfigType config, DeviceScanner scanner, Callable[..., Coroutine[None, None, None]] async_see_device, str platform)
Definition: legacy.py:440
list[Device] async_load_config(str path, HomeAssistant hass, timedelta consider_home)
Definition: legacy.py:1011
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
None open(self, **Any kwargs)
Definition: lock.py:86
Iterable[tuple[str|None, ConfigType]] config_per_platform(ConfigType config, str domain)
Definition: config.py:969
None async_log_schema_error(vol.Invalid exc, str domain, dict config, HomeAssistant hass, str|None link=None)
Definition: config.py:354
dict[Any, Any] load_yaml_config_file(str config_path, Secrets|None secrets=None)
Definition: config.py:268
CALLBACK_TYPE async_track_utc_time_change(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, Any|None hour=None, Any|None minute=None, Any|None second=None, bool local=False)
Definition: event.py:1857
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679
ModuleType|None async_prepare_setup_platform(core.HomeAssistant hass, ConfigType hass_config, str domain, str platform_name)
Definition: setup.py:487
None async_notify_setup_error(HomeAssistant hass, str component, str|None display_link=None)
Definition: setup.py:99
Generator[None] async_start_setup(core.HomeAssistant hass, str integration, SetupPhases phase, str|None group=None)
Definition: setup.py:739
str dump(dict|list _dict)
Definition: dumper.py:21