Home Assistant Unofficial Reference 2024.12.1
device_tracker.py
Go to the documentation of this file.
1 """Track both clients and devices using UniFi Network."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Mapping
6 from dataclasses import dataclass
7 from datetime import timedelta
8 import logging
9 from typing import Any
10 
11 import aiounifi
12 from aiounifi.interfaces.api_handlers import ItemEvent
13 from aiounifi.interfaces.clients import Clients
14 from aiounifi.interfaces.devices import Devices
15 from aiounifi.models.api import ApiItemT
16 from aiounifi.models.client import Client
17 from aiounifi.models.device import Device
18 from aiounifi.models.event import Event, EventKey
19 from propcache import cached_property
20 
22  DOMAIN as DEVICE_TRACKER_DOMAIN,
23  ScannerEntity,
24  ScannerEntityDescription,
25 )
26 from homeassistant.core import Event as core_Event, HomeAssistant, callback
27 from homeassistant.helpers.dispatcher import async_dispatcher_connect
28 from homeassistant.helpers.entity_platform import AddEntitiesCallback
30 import homeassistant.util.dt as dt_util
31 
32 from . import UnifiConfigEntry
33 from .const import DOMAIN as UNIFI_DOMAIN
34 from .entity import (
35  HandlerT,
36  UnifiEntity,
37  UnifiEntityDescription,
38  async_device_available_fn,
39 )
40 from .hub import UnifiHub
41 
42 LOGGER = logging.getLogger(__name__)
43 
44 CLIENT_TRACKER = "client"
45 DEVICE_TRACKER = "device"
46 
47 CLIENT_CONNECTED_ATTRIBUTES = [
48  "_is_guest_by_uap",
49  "ap_mac",
50  "authorized",
51  "essid",
52  "ip",
53  "is_11r",
54  "is_guest",
55  "note",
56  "qos_policy_applied",
57  "radio",
58  "radio_proto",
59  "vlan",
60 ]
61 
62 CLIENT_STATIC_ATTRIBUTES = [
63  "mac",
64  "name",
65  "oui",
66 ]
67 
68 
69 CLIENT_CONNECTED_ALL_ATTRIBUTES = CLIENT_CONNECTED_ATTRIBUTES + CLIENT_STATIC_ATTRIBUTES
70 
71 WIRED_CONNECTION = (EventKey.WIRED_CLIENT_CONNECTED,)
72 WIRED_DISCONNECTION = (EventKey.WIRED_CLIENT_DISCONNECTED,)
73 WIRELESS_CONNECTION = (
74  EventKey.WIRELESS_CLIENT_CONNECTED,
75  EventKey.WIRELESS_CLIENT_ROAM,
76  EventKey.WIRELESS_CLIENT_ROAM_RADIO,
77  EventKey.WIRELESS_GUEST_CONNECTED,
78  EventKey.WIRELESS_GUEST_ROAM,
79  EventKey.WIRELESS_GUEST_ROAM_RADIO,
80 )
81 WIRELESS_DISCONNECTION = (
82  EventKey.WIRELESS_CLIENT_DISCONNECTED,
83  EventKey.WIRELESS_GUEST_DISCONNECTED,
84 )
85 
86 
87 @callback
88 def async_client_allowed_fn(hub: UnifiHub, obj_id: str) -> bool:
89  """Check if client is allowed."""
90  if obj_id in hub.config.option_supported_clients:
91  return True
92 
93  if not hub.config.option_track_clients:
94  return False
95 
96  client = hub.api.clients[obj_id]
97  if client.mac not in hub.entity_loader.wireless_clients:
98  if not hub.config.option_track_wired_clients:
99  return False
100 
101  elif (
102  client.essid
103  and hub.config.option_ssid_filter
104  and client.essid not in hub.config.option_ssid_filter
105  ):
106  return False
107 
108  return True
109 
110 
111 @callback
112 def async_client_is_connected_fn(hub: UnifiHub, obj_id: str) -> bool:
113  """Check if device object is disabled."""
114  client = hub.api.clients[obj_id]
115 
116  if hub.entity_loader.wireless_clients.is_wireless(client) and client.is_wired:
117  if not hub.config.option_ignore_wired_bug:
118  return False # Wired bug in action
119 
120  if (
121  not client.is_wired
122  and client.essid
123  and hub.config.option_ssid_filter
124  and client.essid not in hub.config.option_ssid_filter
125  ):
126  return False
127 
128  if (
129  dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0)
130  > hub.config.option_detection_time
131  ):
132  return False
133 
134  return True
135 
136 
137 @callback
138 def async_device_heartbeat_timedelta_fn(hub: UnifiHub, obj_id: str) -> timedelta:
139  """Check if device object is disabled."""
140  device = hub.api.devices[obj_id]
141  return timedelta(seconds=device.next_interval + 60)
142 
143 
144 @dataclass(frozen=True, kw_only=True)
146  UnifiEntityDescription[HandlerT, ApiItemT], ScannerEntityDescription
147 ):
148  """Class describing UniFi device tracker entity."""
149 
150  heartbeat_timedelta_fn: Callable[[UnifiHub, str], timedelta]
151  ip_address_fn: Callable[[aiounifi.Controller, str], str | None]
152  is_connected_fn: Callable[[UnifiHub, str], bool]
153  hostname_fn: Callable[[aiounifi.Controller, str], str | None]
154 
155 
156 ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = (
157  UnifiTrackerEntityDescription[Clients, Client](
158  key="Client device scanner",
159  allowed_fn=async_client_allowed_fn,
160  api_handler_fn=lambda api: api.clients,
161  device_info_fn=lambda api, obj_id: None,
162  event_is_on=set(WIRED_CONNECTION + WIRELESS_CONNECTION),
163  event_to_subscribe=(
164  WIRED_CONNECTION
165  + WIRED_DISCONNECTION
166  + WIRELESS_CONNECTION
167  + WIRELESS_DISCONNECTION
168  ),
169  heartbeat_timedelta_fn=lambda hub, _: hub.config.option_detection_time,
170  is_connected_fn=async_client_is_connected_fn,
171  name_fn=lambda client: client.name or client.hostname,
172  object_fn=lambda api, obj_id: api.clients[obj_id],
173  unique_id_fn=lambda hub, obj_id: f"{hub.site}-{obj_id}",
174  ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip,
175  hostname_fn=lambda api, obj_id: api.clients[obj_id].hostname,
176  ),
177  UnifiTrackerEntityDescription[Devices, Device](
178  key="Device scanner",
179  allowed_fn=lambda hub, obj_id: hub.config.option_track_devices,
180  api_handler_fn=lambda api: api.devices,
181  available_fn=async_device_available_fn,
182  device_info_fn=lambda api, obj_id: None,
183  heartbeat_timedelta_fn=async_device_heartbeat_timedelta_fn,
184  is_connected_fn=lambda hub, obj_id: hub.api.devices[obj_id].state == 1,
185  name_fn=lambda device: device.name or device.model,
186  object_fn=lambda api, obj_id: api.devices[obj_id],
187  unique_id_fn=lambda hub, obj_id: obj_id,
188  ip_address_fn=lambda api, obj_id: api.devices[obj_id].ip,
189  hostname_fn=lambda api, obj_id: None,
190  ),
191 )
192 
193 
194 @callback
195 def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) -> None:
196  """Normalize client unique ID to have a prefix rather than suffix.
197 
198  Introduced with release 2023.12.
199  """
200  hub = config_entry.runtime_data
201  ent_reg = er.async_get(hass)
202 
203  @callback
204  def update_unique_id(obj_id: str) -> None:
205  """Rework unique ID."""
206  new_unique_id = f"{hub.site}-{obj_id}"
207  if ent_reg.async_get_entity_id(
208  DEVICE_TRACKER_DOMAIN, UNIFI_DOMAIN, new_unique_id
209  ):
210  return
211 
212  unique_id = f"{obj_id}-{hub.site}"
213  if entity_id := ent_reg.async_get_entity_id(
214  DEVICE_TRACKER_DOMAIN, UNIFI_DOMAIN, unique_id
215  ):
216  ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
217 
218  for obj_id in list(hub.api.clients) + list(hub.api.clients_all):
219  update_unique_id(obj_id)
220 
221 
223  hass: HomeAssistant,
224  config_entry: UnifiConfigEntry,
225  async_add_entities: AddEntitiesCallback,
226 ) -> None:
227  """Set up device tracker for UniFi Network integration."""
228  async_update_unique_id(hass, config_entry)
229  config_entry.runtime_data.entity_loader.register_platform(
230  async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS
231  )
232 
233 
234 class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity):
235  """Representation of a UniFi scanner."""
236 
237  entity_description: UnifiTrackerEntityDescription
238 
239  _event_is_on: set[EventKey]
240  _ignore_events: bool
241  _is_connected: bool
242 
243  @callback
244  def async_initiate_state(self) -> None:
245  """Initiate entity state.
246 
247  Initiate is_connected.
248  """
249  description = self.entity_descriptionentity_description
250  self._event_is_on_event_is_on = description.event_is_on or set()
251  self._ignore_events_ignore_events = False
252  self._is_connected_is_connected = description.is_connected_fn(self.hubhub, self._obj_id_obj_id)
253  if self.is_connectedis_connected:
254  self.hubhub.update_heartbeat(
255  self.unique_idunique_idunique_id,
256  dt_util.utcnow()
257  + description.heartbeat_timedelta_fn(self.hubhub, self._obj_id_obj_id),
258  )
259 
260  @property
261  def is_connected(self) -> bool:
262  """Return true if the device is connected to the network."""
263  return self._is_connected_is_connected
264 
265  @property
266  def hostname(self) -> str | None:
267  """Return hostname of the device."""
268  return self.entity_descriptionentity_description.hostname_fn(self.apiapi, self._obj_id_obj_id)
269 
270  @property
271  def ip_address(self) -> str | None:
272  """Return the primary ip address of the device."""
273  return self.entity_descriptionentity_description.ip_address_fn(self.apiapi, self._obj_id_obj_id)
274 
275  @cached_property
276  def mac_address(self) -> str:
277  """Return the mac address of the device."""
278  return self._obj_id_obj_id
279 
280  @cached_property
281  def unique_id(self) -> str:
282  """Return a unique ID."""
283  return self._attr_unique_id_attr_unique_id
284 
285  @callback
286  def _make_disconnected(self, *_: core_Event) -> None:
287  """No heart beat by device."""
288  self._is_connected_is_connected = False
289  self.async_write_ha_stateasync_write_ha_state()
290 
291  @callback
292  def async_update_state(self, event: ItemEvent, obj_id: str) -> None:
293  """Update entity state.
294 
295  Remove heartbeat check if hub connection state has changed
296  and entity is unavailable.
297  Update is_connected.
298  Schedule new heartbeat check if connected.
299  """
300  description = self.entity_descriptionentity_description
301  hub = self.hubhub
302 
303  if event is ItemEvent.CHANGED:
304  # Prioritize normal data updates over events
305  self._ignore_events_ignore_events = True
306 
307  elif event is ItemEvent.ADDED and not self.availableavailable:
308  # From unifi.entity.async_signal_reachable_callback
309  # Controller connection state has changed and entity is unavailable
310  # Cancel heartbeat
311  hub.remove_heartbeat(self.unique_idunique_idunique_id)
312  return
313 
314  obj_id = self._obj_id_obj_id
315  if is_connected := description.is_connected_fn(hub, obj_id):
316  self._is_connected_is_connected = is_connected
317  self.hubhub.update_heartbeat(
318  self.unique_idunique_idunique_id,
319  dt_util.utcnow() + description.heartbeat_timedelta_fn(hub, obj_id),
320  )
321 
322  @callback
323  def async_event_callback(self, event: Event) -> None:
324  """Event subscription callback."""
325  obj_id = self._obj_id_obj_id
326  if event.mac != obj_id or self._ignore_events_ignore_events:
327  return
328 
329  hub = self.hubhub
330  if event.key in self._event_is_on_event_is_on:
331  hub.remove_heartbeat(self.unique_idunique_idunique_id)
332  self._is_connected_is_connected = True
333  self.async_write_ha_stateasync_write_ha_state()
334  return
335 
336  hub.update_heartbeat(
337  self.unique_idunique_idunique_id,
338  dt_util.utcnow()
339  + self.entity_descriptionentity_description.heartbeat_timedelta_fn(hub, obj_id),
340  )
341 
342  async def async_added_to_hass(self) -> None:
343  """Register callbacks."""
344  await super().async_added_to_hass()
345  self.async_on_removeasync_on_remove(
347  self.hasshass,
348  f"{self.hub.signal_heartbeat_missed}_{self.unique_id}",
349  self._make_disconnected_make_disconnected,
350  )
351  )
352 
353  async def async_will_remove_from_hass(self) -> None:
354  """Disconnect object when removed."""
355  await super().async_will_remove_from_hass()
356  self.hubhub.remove_heartbeat(self.unique_idunique_idunique_id)
357 
358  @property
359  def extra_state_attributes(self) -> Mapping[str, Any] | None:
360  """Return the client state attributes."""
361  if self.entity_descriptionentity_description.key != "Client device scanner":
362  return None
363 
364  client = self.entity_descriptionentity_description.object_fn(self.apiapi, self._obj_id_obj_id)
365  raw = client.raw
366 
367  attributes_to_check = CLIENT_STATIC_ATTRIBUTES
368  if self.is_connectedis_connected:
369  attributes_to_check = CLIENT_CONNECTED_ALL_ATTRIBUTES
370 
371  return {k: raw[k] for k in attributes_to_check if k in raw}
None async_update_state(self, ItemEvent event, str obj_id)
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
dict[str, str]|None update_unique_id(er.RegistryEntry entity_entry, str unique_id)
Definition: __init__.py:168
bool async_client_is_connected_fn(UnifiHub hub, str obj_id)
bool async_client_allowed_fn(UnifiHub hub, str obj_id)
None async_setup_entry(HomeAssistant hass, UnifiConfigEntry config_entry, AddEntitiesCallback async_add_entities)
timedelta async_device_heartbeat_timedelta_fn(UnifiHub hub, str obj_id)
None async_update_unique_id(HomeAssistant hass, UnifiConfigEntry config_entry)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103