Home Assistant Unofficial Reference 2024.12.1
device_tracker.py
Go to the documentation of this file.
1 """Tracking for bluetooth devices."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import datetime, timedelta
7 import logging
8 from typing import Final
9 
10 import bluetooth
11 from bt_proximity import BluetoothRSSI
12 import voluptuous as vol
13 
15  CONF_SCAN_INTERVAL,
16  CONF_TRACK_NEW,
17  DEFAULT_TRACK_NEW,
18  PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
19  SCAN_INTERVAL,
20  SourceType,
21 )
23  YAML_DEVICES,
24  AsyncSeeCallback,
25  Device,
26  async_load_config,
27 )
28 from homeassistant.const import CONF_DEVICE_ID
29 from homeassistant.core import HomeAssistant, ServiceCall
31 from homeassistant.helpers.event import async_track_time_interval
32 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
33 
34 from .const import (
35  BT_PREFIX,
36  CONF_REQUEST_RSSI,
37  DEFAULT_DEVICE_ID,
38  DOMAIN,
39  SERVICE_UPDATE,
40 )
41 
42 _LOGGER: Final = logging.getLogger(__name__)
43 
44 PLATFORM_SCHEMA: Final = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
45  {
46  vol.Optional(CONF_TRACK_NEW): cv.boolean,
47  vol.Optional(CONF_REQUEST_RSSI): cv.boolean,
48  vol.Optional(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): vol.All(
49  vol.Coerce(int), vol.Range(min=-1)
50  ),
51  }
52 )
53 
54 
55 def is_bluetooth_device(device: Device) -> bool:
56  """Check whether a device is a bluetooth device by its mac."""
57  return device.mac is not None and device.mac[:3].upper() == BT_PREFIX
58 
59 
60 def discover_devices(device_id: int) -> list[tuple[str, str]]:
61  """Discover Bluetooth devices."""
62  try:
63  result = bluetooth.discover_devices(
64  duration=8,
65  lookup_names=True,
66  flush_cache=True,
67  lookup_class=False,
68  device_id=device_id,
69  )
70  except OSError as ex:
71  # OSError is generally thrown if a bluetooth device isn't found
72  _LOGGER.error("Couldn't discover bluetooth devices: %s", ex)
73  return []
74  _LOGGER.debug("Bluetooth devices discovered = %d", len(result))
75  return result # type: ignore[no-any-return]
76 
77 
78 async def see_device(
79  hass: HomeAssistant,
80  async_see: AsyncSeeCallback,
81  mac: str,
82  device_name: str,
83  rssi: tuple[int] | None = None,
84 ) -> None:
85  """Mark a device as seen."""
86  attributes = {}
87  if rssi is not None:
88  attributes["rssi"] = rssi
89 
90  await async_see(
91  mac=f"{BT_PREFIX}{mac}",
92  host_name=device_name,
93  attributes=attributes,
94  source_type=SourceType.BLUETOOTH,
95  )
96 
97 
98 async def get_tracking_devices(hass: HomeAssistant) -> tuple[set[str], set[str]]:
99  """Load all known devices.
100 
101  We just need the devices so set consider_home and home range to 0
102  """
103  yaml_path: str = hass.config.path(YAML_DEVICES)
104 
105  devices = await async_load_config(yaml_path, hass, timedelta(0))
106  bluetooth_devices = [device for device in devices if is_bluetooth_device(device)]
107 
108  devices_to_track: set[str] = {
109  device.mac[3:]
110  for device in bluetooth_devices
111  if device.track and device.mac is not None
112  }
113  devices_to_not_track: set[str] = {
114  device.mac[3:]
115  for device in bluetooth_devices
116  if not device.track and device.mac is not None
117  }
118 
119  return devices_to_track, devices_to_not_track
120 
121 
122 def lookup_name(mac: str) -> str | None:
123  """Lookup a Bluetooth device name."""
124  _LOGGER.debug("Scanning %s", mac)
125  return bluetooth.lookup_name(mac, timeout=5) # type: ignore[no-any-return]
126 
127 
129  hass: HomeAssistant,
130  config: ConfigType,
131  async_see: AsyncSeeCallback,
132  discovery_info: DiscoveryInfoType | None = None,
133 ) -> bool:
134  """Set up the Bluetooth Scanner."""
135  device_id: int = config[CONF_DEVICE_ID]
136  interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
137  request_rssi: bool = config.get(CONF_REQUEST_RSSI, False)
138  update_bluetooth_lock = asyncio.Lock()
139 
140  # If track new devices is true discover new devices on startup.
141  track_new: bool = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW)
142  _LOGGER.debug("Tracking new devices is set to %s", track_new)
143 
144  devices_to_track, devices_to_not_track = await get_tracking_devices(hass)
145 
146  if not devices_to_track and not track_new:
147  _LOGGER.debug("No Bluetooth devices to track and not tracking new devices")
148 
149  if request_rssi:
150  _LOGGER.debug("Detecting RSSI for devices")
151 
152  async def perform_bluetooth_update() -> None:
153  """Discover Bluetooth devices and update status."""
154  _LOGGER.debug("Performing Bluetooth devices discovery and update")
155  tasks: list[asyncio.Task[None]] = []
156 
157  try:
158  if track_new:
159  devices = await hass.async_add_executor_job(discover_devices, device_id)
160  for mac, _device_name in devices:
161  if mac not in devices_to_track and mac not in devices_to_not_track:
162  devices_to_track.add(mac)
163 
164  for mac in devices_to_track:
165  friendly_name = await hass.async_add_executor_job(lookup_name, mac)
166  if friendly_name is None:
167  # Could not lookup device name
168  continue
169 
170  rssi = None
171  if request_rssi:
172  client = BluetoothRSSI(mac)
173  rssi = await hass.async_add_executor_job(client.request_rssi)
174  client.close()
175 
176  tasks.append(
177  asyncio.create_task(
178  see_device(hass, async_see, mac, friendly_name, rssi)
179  )
180  )
181 
182  if tasks:
183  await asyncio.wait(tasks)
184 
185  except bluetooth.BluetoothError:
186  _LOGGER.exception("Error looking up Bluetooth device")
187 
188  async def update_bluetooth(now: datetime | None = None) -> None:
189  """Lookup Bluetooth devices and update status."""
190  # If an update is in progress, we don't do anything
191  if update_bluetooth_lock.locked():
192  _LOGGER.debug(
193  (
194  "Previous execution of update_bluetooth is taking longer than the"
195  " scheduled update of interval %s"
196  ),
197  interval,
198  )
199  return
200 
201  async with update_bluetooth_lock:
202  await perform_bluetooth_update()
203 
204  async def handle_manual_update_bluetooth(call: ServiceCall) -> None:
205  """Update bluetooth devices on demand."""
206  await update_bluetooth()
207 
208  hass.async_create_task(update_bluetooth())
209  async_track_time_interval(hass, update_bluetooth, interval)
210 
211  hass.services.async_register(DOMAIN, SERVICE_UPDATE, handle_manual_update_bluetooth)
212 
213  return True
list[tuple[str, str]] discover_devices(int device_id)
bool async_setup_scanner(HomeAssistant hass, ConfigType config, AsyncSeeCallback async_see, DiscoveryInfoType|None discovery_info=None)
tuple[set[str], set[str]] get_tracking_devices(HomeAssistant hass)
None see_device(HomeAssistant hass, AsyncSeeCallback async_see, str mac, str device_name, tuple[int]|None rssi=None)
list[Device] async_load_config(str path, HomeAssistant hass, timedelta consider_home)
Definition: legacy.py:1011
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