Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update bluetooth_le_tracker to use Bleak #75013

Merged
merged 28 commits into from Jul 19, 2022
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
978c47f
Update bluetooth_le_tracker to use Bleak
bdraco Jul 11, 2022
118d263
Update homeassistant/components/bluetooth_le_tracker/device_tracker.py
bdraco Jul 12, 2022
98181ad
Update homeassistant/components/bluetooth_le_tracker/device_tracker.py
bdraco Jul 12, 2022
ba00372
Update homeassistant/components/bluetooth_le_tracker/device_tracker.py
bdraco Jul 12, 2022
1ca9457
Update homeassistant/components/bluetooth_le_tracker/device_tracker.py
bdraco Jul 12, 2022
17ad22f
Merge branch 'dev' into btle_tracker
bdraco Jul 12, 2022
a8a3059
set
bdraco Jul 12, 2022
b135bc0
still do interval updates to ensure everything stays home
bdraco Jul 12, 2022
e884965
still do interval updates to ensure everything stays home
bdraco Jul 12, 2022
e735762
still do interval updates to ensure everything stays home
bdraco Jul 12, 2022
61a3474
naming
bdraco Jul 12, 2022
a09fb78
naming
bdraco Jul 12, 2022
092e6df
adapt test
bdraco Jul 12, 2022
d6a287a
Add mock_bluetooth fixture
bdraco Jul 12, 2022
3bfbfcc
Merge branch 'bluetooth_fixtures' into btle_tracker
bdraco Jul 12, 2022
5ddc99b
mock bluetooth in test
bdraco Jul 12, 2022
aa972dc
mock bluetooth in test
bdraco Jul 12, 2022
376beac
Merge branch 'dev' into btle_tracker
bdraco Jul 14, 2022
41bdb66
Merge branch 'dev' into btle_tracker
bdraco Jul 14, 2022
b1e6c26
Merge branch 'dev' into btle_tracker
bdraco Jul 16, 2022
4c63aba
Add device and advertisement to BluetoothServiceInfoBleak
bdraco Jul 17, 2022
23db047
convert address to bledevice
bdraco Jul 17, 2022
0ec7531
convert address to bledevice
bdraco Jul 17, 2022
2cb7319
Merge branch 'service_info_with_bleak' into btle_tracker
bdraco Jul 18, 2022
d4ac77f
fixes
bdraco Jul 18, 2022
6f29ef2
cover
bdraco Jul 18, 2022
231c4e8
Update homeassistant/components/bluetooth_le_tracker/device_tracker.py
bdraco Jul 18, 2022
d0125c1
Merge branch 'dev' into btle_tracker
bdraco Jul 18, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
182 changes: 89 additions & 93 deletions homeassistant/components/bluetooth_le_tracker/device_tracker.py
Expand Up @@ -2,19 +2,19 @@
from __future__ import annotations

import asyncio
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
import logging
from uuid import UUID

import pygatt
from bleak import BleakClient, BleakError
import voluptuous as vol

from homeassistant.components import bluetooth
from homeassistant.components.device_tracker import (
PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA,
)
from homeassistant.components.device_tracker.const import (
CONF_SCAN_INTERVAL,
CONF_TRACK_NEW,
SCAN_INTERVAL,
SOURCE_TYPE_BLUETOOTH_LE,
Expand All @@ -23,10 +23,10 @@
YAML_DEVICES,
async_load_config,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import track_point_in_utc_time
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util

Expand All @@ -53,33 +53,31 @@
)


def setup_scanner( # noqa: C901
async def async_setup_scanner( # noqa: C901
hass: HomeAssistant,
config: ConfigType,
see: Callable[..., None],
async_see: Callable[..., Awaitable[None]],
discovery_info: DiscoveryInfoType | None = None,
) -> bool:
"""Set up the Bluetooth LE Scanner."""

new_devices: dict[str, dict] = {}
hass.data.setdefault(DATA_BLE, {DATA_BLE_ADAPTER: None})

def handle_stop(event):
"""Try to shut down the bluetooth child process nicely."""
# These should never be unset at the point this runs, but just for
# safety's sake, use `get`.
adapter = hass.data.get(DATA_BLE, {}).get(DATA_BLE_ADAPTER)
if adapter is not None:
adapter.kill()

hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop)

if config[CONF_TRACK_BATTERY]:
battery_track_interval = config[CONF_TRACK_BATTERY_INTERVAL]
else:
battery_track_interval = timedelta(0)

def see_device(address, name, new_device=False, battery=None):
yaml_path = hass.config.path(YAML_DEVICES)
devs_to_track: set[str] = set()
devs_no_track: set[str] = set()
devs_track_battery = {}
interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
# if track new devices is true discover new devices
# on every scan.
track_new = config.get(CONF_TRACK_NEW)

async def async_see_device(address, name, new_device=False, battery=None):
"""Mark a device as seen."""
if name is not None:
name = name.strip("\x00")
Expand All @@ -95,7 +93,7 @@ def see_device(address, name, new_device=False, battery=None):
if new_devices[address]["seen"] < MIN_SEEN_NEW:
return
_LOGGER.debug("Adding %s to tracked devices", address)
devs_to_track.append(address)
devs_to_track.add(address)
if battery_track_interval > timedelta(0):
devs_track_battery[address] = dt_util.as_utc(
datetime.fromtimestamp(0)
Expand All @@ -105,109 +103,107 @@ def see_device(address, name, new_device=False, battery=None):
new_devices[address] = {"seen": 1, "name": name}
return

see(
await async_see(
mac=BLE_PREFIX + address,
host_name=name,
source_type=SOURCE_TYPE_BLUETOOTH_LE,
battery=battery,
)

def discover_ble_devices():
"""Discover Bluetooth LE devices."""
_LOGGER.debug("Discovering Bluetooth LE devices")
try:
adapter = pygatt.GATTToolBackend()
hass.data[DATA_BLE][DATA_BLE_ADAPTER] = adapter
devs = adapter.scan()

devices = {x["address"]: x["name"] for x in devs}
_LOGGER.debug("Bluetooth LE devices discovered = %s", devices)
except (RuntimeError, pygatt.exceptions.BLEError) as error:
_LOGGER.error("Error during Bluetooth LE scan: %s", error)
return {}
return devices

yaml_path = hass.config.path(YAML_DEVICES)
devs_to_track = []
devs_donot_track = []
devs_track_battery = {}

# Load all known devices.
# We just need the devices so set consider_home and home range
# to 0
for device in asyncio.run_coroutine_threadsafe(
async_load_config(yaml_path, hass, timedelta(0)), hass.loop
).result():
for device in await async_load_config(yaml_path, hass, timedelta(0)):
# check if device is a valid bluetooth device
if device.mac and device.mac[:4].upper() == BLE_PREFIX:
address = device.mac[4:]
if device.track:
_LOGGER.debug("Adding %s to BLE tracker", device.mac)
devs_to_track.append(address)
devs_to_track.add(address)
if battery_track_interval > timedelta(0):
devs_track_battery[address] = dt_util.as_utc(
datetime.fromtimestamp(0)
)
else:
_LOGGER.debug("Adding %s to BLE do not track", device.mac)
devs_donot_track.append(address)

# if track new devices is true discover new devices
# on every scan.
track_new = config.get(CONF_TRACK_NEW)
devs_no_track.add(address)

if not devs_to_track and not track_new:
_LOGGER.warning("No Bluetooth LE devices to track!")
return False

interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)

def update_ble(now):
async def _async_see_update_ble_battery(
mac: str,
now: datetime,
service_info: bluetooth.BluetoothServiceInfo,
) -> None:
"""Lookup Bluetooth LE devices and update status."""
devs = discover_ble_devices()
if devs_track_battery:
adapter = hass.data[DATA_BLE][DATA_BLE_ADAPTER]
for mac in devs_to_track:
if mac not in devs:
continue

if devs[mac] is None:
devs[mac] = mac

battery = None
battery = None
try:
async with BleakClient(mac) as client:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID)
battery = ord(bat_char)
except asyncio.TimeoutError:
_LOGGER.warning(
"Timeout when trying to get battery status for %s", service_info.name
)
except BleakError as err:
_LOGGER.debug("Could not read battery status: %s", err)
# If the device does not offer battery information, there is no point in asking again later on.
# Remove the device from the battery-tracked devices, so that their battery is not wasted
# trying to get an unavailable information.
del devs_track_battery[mac]
if battery:
await async_see_device(mac, service_info.name, battery=battery)

@callback
def _async_update_ble(
service_info: bluetooth.BluetoothServiceInfo, change: bluetooth.BluetoothChange
) -> None:
"""Update from a ble callback."""
mac = service_info.address
if mac in devs_to_track:
now = dt_util.utcnow()
hass.async_create_task(async_see_device(mac, service_info.name))
if (
mac in devs_track_battery
and now > devs_track_battery[mac] + battery_track_interval
):
handle = None
try:
adapter.start(reset_on_start=False)
_LOGGER.debug("Reading battery for Bluetooth LE device %s", mac)
bt_device = adapter.connect(mac)
# Try to get the handle; it will raise a BLEError exception if not available
handle = bt_device.get_handle(BATTERY_CHARACTERISTIC_UUID)
battery = ord(bt_device.char_read(BATTERY_CHARACTERISTIC_UUID))
devs_track_battery[mac] = now
except pygatt.exceptions.NotificationTimeout:
_LOGGER.warning("Timeout when trying to get battery status")
except pygatt.exceptions.BLEError as err:
_LOGGER.warning("Could not read battery status: %s", err)
if handle is not None:
# If the device does not offer battery information, there is no point in asking again later on.
# Remove the device from the battery-tracked devices, so that their battery is not wasted
# trying to get an unavailable information.
del devs_track_battery[mac]
finally:
adapter.stop()
see_device(mac, devs[mac], battery=battery)
devs_track_battery[mac] = now
asyncio.create_task(
_async_see_update_ble_battery(mac, now, service_info)
)

if track_new:
for address in devs:
if address not in devs_to_track and address not in devs_donot_track:
_LOGGER.info("Discovered Bluetooth LE device %s", address)
see_device(address, devs[address], new_device=True)

track_point_in_utc_time(hass, update_ble, dt_util.utcnow() + interval)
if mac not in devs_to_track and mac not in devs_no_track:
_LOGGER.info("Discovered Bluetooth LE device %s", mac)
hass.async_create_task(
async_see_device(mac, service_info.name, new_device=True)
)

@callback
def _async_refresh_ble(now: datetime) -> None:
"""Refresh BLE devices from the discovered service info."""
# Make sure devices are seen again at the scheduled
# interval so they do not get set to not_home when
# there have been no callbacks because the RSSI or
# other properties have not changed.
for service_info in bluetooth.async_discovered_service_info(hass):
_async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT)

cancels = [
bluetooth.async_register_callback(hass, _async_update_ble, None),
async_track_time_interval(hass, _async_refresh_ble, interval),
]

@callback
def _async_handle_stop(event: Event) -> None:
"""Cancel the callback."""
for cancel in cancels:
cancel()

hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_handle_stop)

_async_refresh_ble(dt_util.now())

update_ble(dt_util.utcnow())
return True
6 changes: 3 additions & 3 deletions homeassistant/components/bluetooth_le_tracker/manifest.json
Expand Up @@ -2,8 +2,8 @@
"domain": "bluetooth_le_tracker",
"name": "Bluetooth LE Tracker",
"documentation": "https://www.home-assistant.io/integrations/bluetooth_le_tracker",
"requirements": ["pygatt[GATTTOOL]==4.0.5"],
"dependencies": ["bluetooth"],
"codeowners": [],
"iot_class": "local_polling",
"loggers": ["pygatt"]
"iot_class": "local_push",
"loggers": []
}
1 change: 0 additions & 1 deletion requirements_all.txt
Expand Up @@ -1527,7 +1527,6 @@ pyfronius==0.7.1
# homeassistant.components.ifttt
pyfttt==0.3

# homeassistant.components.bluetooth_le_tracker
# homeassistant.components.skybeacon
pygatt[GATTTOOL]==4.0.5

Expand Down
4 changes: 0 additions & 4 deletions requirements_test_all.txt
Expand Up @@ -1033,10 +1033,6 @@ pyfronius==0.7.1
# homeassistant.components.ifttt
pyfttt==0.3

# homeassistant.components.bluetooth_le_tracker
# homeassistant.components.skybeacon
pygatt[GATTTOOL]==4.0.5

# homeassistant.components.hvv_departures
pygti==0.9.2

Expand Down
7 changes: 7 additions & 0 deletions tests/components/bluetooth_le_tracker/conftest.py
@@ -0,0 +1,7 @@
"""Tests for the bluetooth_le_tracker component."""
import pytest


@pytest.fixture(autouse=True)
def bluetooth_le_tracker_auto_mock_bluetooth(mock_bluetooth):
"""Mock the bluetooth integration scanner."""
34 changes: 27 additions & 7 deletions tests/components/bluetooth_le_tracker/test_device_tracker.py
Expand Up @@ -3,6 +3,7 @@
from datetime import timedelta
from unittest.mock import patch

from homeassistant.components.bluetooth import BluetoothServiceInfo
from homeassistant.components.bluetooth_le_tracker import device_tracker
from homeassistant.components.device_tracker.const import (
CONF_SCAN_INTERVAL,
Expand All @@ -16,21 +17,31 @@
from tests.common import async_fire_time_changed


async def test_preserve_new_tracked_device_name(hass, mock_device_tracker_conf):
async def test_preserve_new_tracked_device_name(
hass, mock_bluetooth, mock_device_tracker_conf
):
"""Test preserving tracked device name across new seens."""

address = "DE:AD:BE:EF:13:37"
name = "Mock device name"
entity_id = f"{DOMAIN}.{slugify(name)}"

with patch(
"homeassistant.components."
"bluetooth_le_tracker.device_tracker.pygatt.GATTToolBackend"
) as mock_backend, patch.object(device_tracker, "MIN_SEEN_NEW", 3):
"homeassistant.components.bluetooth.async_discovered_service_info"
) as mock_async_discovered_service_info, patch.object(
device_tracker, "MIN_SEEN_NEW", 3
):

device = BluetoothServiceInfo(
name=name,
address=address,
rssi=-19,
manufacturer_data={},
service_data={},
service_uuids=[],
)
# Return with name when seen first time
device = {"address": address, "name": name}
mock_backend.return_value.scan.return_value = [device]
mock_async_discovered_service_info.return_value = [device]

config = {
CONF_PLATFORM: "bluetooth_le_tracker",
Expand All @@ -41,7 +52,16 @@ async def test_preserve_new_tracked_device_name(hass, mock_device_tracker_conf):
assert result

# Seen once here; return without name when seen subsequent times
device["name"] = None
BluetoothServiceInfo(
name=None,
address=address,
rssi=-19,
manufacturer_data={},
service_data={},
service_uuids=[],
)
# Return with name when seen first time
mock_async_discovered_service_info.return_value = [device]

# Tick until device seen enough times for to be registered for tracking
for _ in range(device_tracker.MIN_SEEN_NEW - 1):
Expand Down