Home Assistant Unofficial Reference 2024.12.1
device_tracker.py
Go to the documentation of this file.
1 """Tracking for bluetooth low energy devices."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime, timedelta
6 import logging
7 from uuid import UUID
8 
9 from bleak import BleakClient, BleakError
10 import voluptuous as vol
11 
12 from homeassistant.components import bluetooth
13 from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
15  CONF_TRACK_NEW,
16  PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
17  SCAN_INTERVAL,
18  SourceType,
19 )
21  YAML_DEVICES,
22  AsyncSeeCallback,
23  async_load_config,
24 )
25 from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP
26 from homeassistant.core import Event, HomeAssistant, callback
28 from homeassistant.helpers.event import async_track_time_interval
29 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
30 import homeassistant.util.dt as dt_util
31 
32 _LOGGER = logging.getLogger(__name__)
33 
34 # Base UUID: 00000000-0000-1000-8000-00805F9B34FB
35 # Battery characteristic: 0x2a19 (https://www.bluetooth.com/specifications/gatt/characteristics/)
36 BATTERY_CHARACTERISTIC_UUID = UUID("00002a19-0000-1000-8000-00805f9b34fb")
37 CONF_TRACK_BATTERY = "track_battery"
38 CONF_TRACK_BATTERY_INTERVAL = "track_battery_interval"
39 DEFAULT_TRACK_BATTERY_INTERVAL = timedelta(days=1)
40 DATA_BLE = "BLE"
41 DATA_BLE_ADAPTER = "ADAPTER"
42 BLE_PREFIX = "BLE_"
43 MIN_SEEN_NEW = 5
44 
45 PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
46  {
47  vol.Optional(CONF_TRACK_BATTERY, default=False): cv.boolean,
48  vol.Optional(
49  CONF_TRACK_BATTERY_INTERVAL, default=DEFAULT_TRACK_BATTERY_INTERVAL
50  ): cv.time_period,
51  }
52 )
53 
54 
55 async def async_setup_scanner( # noqa: C901
56  hass: HomeAssistant,
57  config: ConfigType,
58  async_see: AsyncSeeCallback,
59  discovery_info: DiscoveryInfoType | None = None,
60 ) -> bool:
61  """Set up the Bluetooth LE Scanner."""
62 
63  new_devices: dict[str, dict] = {}
64 
65  if config[CONF_TRACK_BATTERY]:
66  battery_track_interval = config[CONF_TRACK_BATTERY_INTERVAL]
67  else:
68  battery_track_interval = timedelta(0)
69 
70  yaml_path = hass.config.path(YAML_DEVICES)
71  devs_to_track: set[str] = set()
72  devs_no_track: set[str] = set()
73  devs_advertise_time: dict[str, float] = {}
74  devs_track_battery = {}
75  interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
76  # if track new devices is true discover new devices
77  # on every scan.
78  track_new = config.get(CONF_TRACK_NEW)
79 
80  async def async_see_device(address, name, new_device=False, battery=None):
81  """Mark a device as seen."""
82  if name is not None:
83  name = name.strip("\x00")
84 
85  if new_device:
86  if address in new_devices:
87  new_devices[address]["seen"] += 1
88  if name:
89  new_devices[address]["name"] = name
90  else:
91  name = new_devices[address]["name"]
92  _LOGGER.debug("Seen %s %s times", address, new_devices[address]["seen"])
93  if new_devices[address]["seen"] < MIN_SEEN_NEW:
94  return
95  _LOGGER.debug("Adding %s to tracked devices", address)
96  devs_to_track.add(address)
97  if battery_track_interval > timedelta(0):
98  devs_track_battery[address] = dt_util.as_utc(
99  datetime.fromtimestamp(0)
100  )
101  else:
102  _LOGGER.debug("Seen %s for the first time", address)
103  new_devices[address] = {"seen": 1, "name": name}
104  return
105 
106  await async_see(
107  mac=BLE_PREFIX + address,
108  host_name=name,
109  source_type=SourceType.BLUETOOTH_LE,
110  battery=battery,
111  )
112 
113  # Load all known devices.
114  # We just need the devices so set consider_home and home range
115  # to 0
116  for device in await async_load_config(yaml_path, hass, timedelta(0)):
117  # check if device is a valid bluetooth device
118  if device.mac and device.mac[:4].upper() == BLE_PREFIX:
119  address = device.mac[4:]
120  if device.track:
121  _LOGGER.debug("Adding %s to BLE tracker", device.mac)
122  devs_to_track.add(address)
123  if battery_track_interval > timedelta(0):
124  devs_track_battery[address] = dt_util.as_utc(
125  datetime.fromtimestamp(0)
126  )
127  else:
128  _LOGGER.debug("Adding %s to BLE do not track", device.mac)
129  devs_no_track.add(address)
130 
131  if not devs_to_track and not track_new:
132  _LOGGER.warning("No Bluetooth LE devices to track!")
133  return False
134 
135  async def _async_see_update_ble_battery(
136  mac: str,
137  now: datetime,
138  service_info: bluetooth.BluetoothServiceInfoBleak,
139  ) -> None:
140  """Lookup Bluetooth LE devices and update status."""
141  battery = None
142  # We need one we can connect to since the tracker will
143  # accept devices from non-connectable sources
144  if service_info.connectable:
145  device = service_info.device
146  elif connectable_device := bluetooth.async_ble_device_from_address(
147  hass, service_info.device.address, True
148  ):
149  device = connectable_device
150  else:
151  # The device can be seen by a passive tracker but we
152  # don't have a route to make a connection
153  return
154  try:
155  async with BleakClient(device) as client:
156  bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID)
157  battery = ord(bat_char)
158  except TimeoutError:
159  _LOGGER.debug(
160  "Timeout when trying to get battery status for %s", service_info.name
161  )
162  # Bleak currently has a few places where checking dbus attributes
163  # can raise when there is another error. We need to trap AttributeError
164  # until bleak releases v0.15+ which resolves these.
165  except (AttributeError, BleakError) as err:
166  _LOGGER.debug("Could not read battery status: %s", err)
167  # If the device does not offer battery information, there is no point in asking again later on.
168  # Remove the device from the battery-tracked devices, so that their battery is not wasted
169  # trying to get an unavailable information.
170  del devs_track_battery[mac]
171  if battery:
172  await async_see_device(mac, service_info.name, battery=battery)
173 
174  @callback
175  def _async_update_ble(
176  service_info: bluetooth.BluetoothServiceInfoBleak,
177  change: bluetooth.BluetoothChange,
178  ) -> None:
179  """Update from a ble callback."""
180  mac = service_info.address
181  if mac in devs_to_track:
182  devs_advertise_time[mac] = service_info.time
183  now = dt_util.utcnow()
184  hass.async_create_task(async_see_device(mac, service_info.name))
185  if (
186  mac in devs_track_battery
187  and now > devs_track_battery[mac] + battery_track_interval
188  ):
189  devs_track_battery[mac] = now
190  hass.async_create_background_task(
191  _async_see_update_ble_battery(mac, now, service_info),
192  "bluetooth_le_tracker.device_tracker-see_update_ble_battery",
193  )
194 
195  if track_new:
196  if mac not in devs_to_track and mac not in devs_no_track:
197  _LOGGER.debug("Discovered Bluetooth LE device %s", mac)
198  hass.async_create_task(
199  async_see_device(mac, service_info.name, new_device=True)
200  )
201 
202  @callback
203  def _async_refresh_ble(now: datetime) -> None:
204  """Refresh BLE devices from the discovered service info."""
205  # Make sure devices are seen again at the scheduled
206  # interval so they do not get set to not_home when
207  # there have been no callbacks because the RSSI or
208  # other properties have not changed.
209  for service_info in bluetooth.async_discovered_service_info(hass, False):
210  # Only call _async_update_ble if the advertisement time has changed
211  if service_info.time != devs_advertise_time.get(service_info.address):
212  _async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT)
213 
214  cancels = [
215  bluetooth.async_register_callback(
216  hass,
217  _async_update_ble,
219  connectable=False
220  ), # We will take data from any source
221  bluetooth.BluetoothScanningMode.ACTIVE,
222  ),
223  async_track_time_interval(hass, _async_refresh_ble, interval),
224  ]
225 
226  @callback
227  def _async_handle_stop(event: Event) -> None:
228  """Cancel the callback."""
229  for cancel in cancels:
230  cancel()
231 
232  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_handle_stop)
233 
234  _async_refresh_ble(dt_util.now())
235 
236  return True
bool async_setup_scanner(HomeAssistant hass, ConfigType config, AsyncSeeCallback async_see, DiscoveryInfoType|None discovery_info=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