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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add bluetooth integration #74653

Merged
merged 84 commits into from
Jul 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
0daaa33
Start with the basics
bdraco Jul 7, 2022
47227aa
remove boilerplate
bdraco Jul 7, 2022
0068457
naming
bdraco Jul 7, 2022
7ad196a
naming
bdraco Jul 7, 2022
61bed2f
some basics
bdraco Jul 7, 2022
118ba4d
working scanner
bdraco Jul 7, 2022
0d13aff
wrap bleak scanner
bdraco Jul 7, 2022
59a6bcc
callback interface
bdraco Jul 7, 2022
e6ba9a3
start sonner to capture
bdraco Jul 7, 2022
07d7d74
active passive
bdraco Jul 7, 2022
dbaeab1
mac hides the mac
bdraco Jul 7, 2022
fa1f707
do not expose raw data
bdraco Jul 7, 2022
d5a43d1
tweak
bdraco Jul 7, 2022
9f89425
tweak
bdraco Jul 7, 2022
9b9c10d
always get a name
bdraco Jul 7, 2022
f043bcc
wip
bdraco Jul 7, 2022
18f09ab
wip
bdraco Jul 7, 2022
30fd094
handle bleak filters
bdraco Jul 7, 2022
ee65ca7
handle bleak filters
bdraco Jul 7, 2022
2574a4b
handle bleak filters
bdraco Jul 7, 2022
a0104e0
hassfest
bdraco Jul 7, 2022
0b9d9bc
hassfest
bdraco Jul 7, 2022
ec8ffb5
merge
bdraco Jul 7, 2022
4c5f68d
drop bind_hass
bdraco Jul 7, 2022
c0eeaab
remove testing
bdraco Jul 7, 2022
a4d8fd1
strict typing
bdraco Jul 7, 2022
6ba6a7b
strict typing
bdraco Jul 7, 2022
27d45b2
typo
bdraco Jul 7, 2022
9deeced
drop boiler plate
bdraco Jul 7, 2022
a848d8b
debug
bdraco Jul 7, 2022
bf2a33e
stringify since linux backend is different
bdraco Jul 7, 2022
29f3acd
typing fixes
bdraco Jul 7, 2022
da9a664
fix linux
bdraco Jul 7, 2022
a803223
fix linux
bdraco Jul 7, 2022
8636a21
document it
bdraco Jul 7, 2022
36f37e5
reorder
bdraco Jul 7, 2022
c2404e2
replay the history on subscribe
bdraco Jul 7, 2022
0dd2f91
fixes
bdraco Jul 7, 2022
4b73173
fix typing
bdraco Jul 7, 2022
2d2bd08
fix typing
bdraco Jul 7, 2022
ece37bf
callbacks
bdraco Jul 8, 2022
183763f
callbacks
bdraco Jul 8, 2022
b3ce45c
handle removes
bdraco Jul 8, 2022
184d214
fix failure to start switchbot
bdraco Jul 8, 2022
b949406
cleanups
bdraco Jul 8, 2022
7d2853c
cleanups
bdraco Jul 8, 2022
30b8f87
Revert "cleanups"
bdraco Jul 8, 2022
9febcfa
tweak
bdraco Jul 8, 2022
ea81c52
tweak
bdraco Jul 8, 2022
f53d8e4
fixes
bdraco Jul 8, 2022
9e2392b
callbacks
bdraco Jul 8, 2022
1148180
Update homeassistant/components/bluetooth/__init__.py
bdraco Jul 8, 2022
6cb1552
freeze it
bdraco Jul 8, 2022
e3e1ee5
callback
bdraco Jul 8, 2022
0626d1a
Merge branch 'bt' of github.com:bdraco/home-assistant into bt
bdraco Jul 8, 2022
bb6e0a8
callback
bdraco Jul 8, 2022
d4b4a7f
unfreeze
bdraco Jul 8, 2022
61b8a4f
take out fake subscriber
bdraco Jul 8, 2022
b1ca624
cleanup hassfest
bdraco Jul 8, 2022
4733bd6
naming
bdraco Jul 8, 2022
46dca71
we need RSSI updates for trackers
bdraco Jul 8, 2022
d185db2
reduce overhead
bdraco Jul 8, 2022
991a4b6
Merge branch 'bt' of github.com:bdraco/home-assistant into bt
bdraco Jul 8, 2022
ada1746
Revert "reduce overhead"
bdraco Jul 8, 2022
543dfda
filter
bdraco Jul 8, 2022
6ca6d50
Revert "filter"
bdraco Jul 8, 2022
2d2d263
adjust
bdraco Jul 8, 2022
d399534
init tests
bdraco Jul 8, 2022
df5df97
cover
bdraco Jul 8, 2022
f45a544
more tests
bdraco Jul 8, 2022
7cda367
tests for matching to homekit_controller
bdraco Jul 8, 2022
75244c7
more tests
bdraco Jul 8, 2022
94b7196
hkc tests
bdraco Jul 8, 2022
17d95fe
verify callback cancel works
bdraco Jul 8, 2022
712c42c
Merge branch 'dev' into bt
bdraco Jul 8, 2022
d996259
test for wrapped instances
bdraco Jul 8, 2022
322b71b
fail case
bdraco Jul 8, 2022
6aaa30d
wrap instead
bdraco Jul 8, 2022
5b6c4a2
patch stop as well since its a bit different on linux
bdraco Jul 8, 2022
dd38c8c
patch stop as well since its a bit different on linux
bdraco Jul 8, 2022
1395e83
fixture expires too soon, patch it out
bdraco Jul 8, 2022
f359571
comment
bdraco Jul 8, 2022
7111873
coverage
bdraco Jul 8, 2022
7afb792
missing cover
bdraco Jul 8, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ homeassistant.components.automation.*
homeassistant.components.backup.*
homeassistant.components.baf.*
homeassistant.components.binary_sensor.*
homeassistant.components.bluetooth.*
homeassistant.components.bluetooth_tracker.*
homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/blueprint/ @home-assistant/core
/tests/components/blueprint/ @home-assistant/core
/homeassistant/components/bluesound/ @thrawnarn
/homeassistant/components/bluetooth/ @bdraco
/tests/components/bluetooth/ @bdraco
/homeassistant/components/bmw_connected_drive/ @gerard33 @rikroe
/tests/components/bmw_connected_drive/ @gerard33 @rikroe
/homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
# To record data
"recorder",
}
DISCOVERY_INTEGRATIONS = ("dhcp", "ssdp", "usb", "zeroconf")
DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb", "zeroconf")
STAGE_1_INTEGRATIONS = {
# We need to make sure discovery integrations
# update their deps before stage 2 integrations
Expand Down
297 changes: 297 additions & 0 deletions homeassistant/components/bluetooth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
"""The bluetooth integration."""
from __future__ import annotations

from collections.abc import Callable
import dataclasses
from enum import Enum
import fnmatch
from functools import cached_property
import logging
import platform
from typing import Final

from bleak import BleakError
from bleak.backends.device import MANUFACTURERS, BLEDevice
from bleak.backends.scanner import AdvertisementData
from lru import LRU # pylint: disable=no-name-in-module

from homeassistant import config_entries
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
CALLBACK_TYPE,
Event,
HomeAssistant,
callback as hass_callback,
)
from homeassistant.data_entry_flow import BaseServiceInfo
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import BluetoothMatcher, async_get_bluetooth

from . import models
from .const import DOMAIN
from .models import HaBleakScanner
from .usage import install_multiple_bleak_catcher

_LOGGER = logging.getLogger(__name__)

MAX_REMEMBER_ADDRESSES: Final = 2048


class BluetoothScanningMode(Enum):
"""The mode of scanning for bluetooth devices."""

PASSIVE = "passive"
ACTIVE = "active"


SCANNING_MODE_TO_BLEAK = {
BluetoothScanningMode.ACTIVE: "active",
BluetoothScanningMode.PASSIVE: "passive",
}

LOCAL_NAME: Final = "local_name"
SERVICE_UUID: Final = "service_uuid"
MANUFACTURER_ID: Final = "manufacturer_id"
MANUFACTURER_DATA_FIRST_BYTE: Final = "manufacturer_data_first_byte"


@dataclasses.dataclass
bdraco marked this conversation as resolved.
Show resolved Hide resolved
class BluetoothServiceInfo(BaseServiceInfo):
"""Prepared info from bluetooth entries."""

name: str
address: str
rssi: int
manufacturer_data: dict[int, bytes]
service_data: dict[str, bytes]
service_uuids: list[str]

@classmethod
def from_advertisement(
cls, device: BLEDevice, advertisement_data: AdvertisementData
) -> BluetoothServiceInfo:
"""Create a BluetoothServiceInfo from an advertisement."""
return cls(
name=advertisement_data.local_name or device.name or device.address,
address=device.address,
rssi=device.rssi,
manufacturer_data=advertisement_data.manufacturer_data,
service_data=advertisement_data.service_data,
service_uuids=advertisement_data.service_uuids,
)

@cached_property
def manufacturer(self) -> str | None:
"""Convert manufacturer data to a string."""
for manufacturer in self.manufacturer_data:
if manufacturer in MANUFACTURERS:
name: str = MANUFACTURERS[manufacturer]
return name
return None

@cached_property
def manufacturer_id(self) -> int | None:
"""Get the first manufacturer id."""
for manufacturer in self.manufacturer_data:
return manufacturer
return None


BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT")
BluetoothCallback = Callable[[BluetoothServiceInfo, BluetoothChange], None]


@hass_callback
def async_register_callback(
hass: HomeAssistant,
callback: BluetoothCallback,
match_dict: BluetoothMatcher | None,
) -> Callable[[], None]:
"""Register to receive a callback on bluetooth change.

Returns a callback that can be used to cancel the registration.
"""
manager: BluetoothManager = hass.data[DOMAIN]
return manager.async_register_callback(callback, match_dict)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the bluetooth integration."""
integration_matchers = await async_get_bluetooth(hass)
bluetooth_discovery = BluetoothManager(
hass, integration_matchers, BluetoothScanningMode.PASSIVE
)
await bluetooth_discovery.async_setup()
hass.data[DOMAIN] = bluetooth_discovery
return True


def _ble_device_matches(
matcher: BluetoothMatcher, device: BLEDevice, advertisement_data: AdvertisementData
) -> bool:
"""Check if a ble device and advertisement_data matches the matcher."""
if (
matcher_local_name := matcher.get(LOCAL_NAME)
) is not None and not fnmatch.fnmatch(
advertisement_data.local_name or device.name or device.address,
matcher_local_name,
):
return False

if (
matcher_service_uuid := matcher.get(SERVICE_UUID)
) is not None and matcher_service_uuid not in advertisement_data.service_uuids:
return False

if (
(matcher_manfacturer_id := matcher.get(MANUFACTURER_ID)) is not None
and matcher_manfacturer_id not in advertisement_data.manufacturer_data
):
return False

if (
matcher_manufacturer_data_first_byte := matcher.get(
MANUFACTURER_DATA_FIRST_BYTE
)
) is not None and not any(
matcher_manufacturer_data_first_byte == manufacturer_data[0]
for manufacturer_data in advertisement_data.manufacturer_data.values()
):
return False

return True


@hass_callback
def async_enable_rssi_updates() -> None:
"""Bleak filters out RSSI updates by default on linux only."""
# We want RSSI updates
if platform.system() == "Linux":
from bleak.backends.bluezdbus import ( # pylint: disable=import-outside-toplevel
scanner,
)

scanner._ADVERTISING_DATA_PROPERTIES.add( # pylint: disable=protected-access
"RSSI"
)


class BluetoothManager:
"""Manage Bluetooth."""

def __init__(
self,
hass: HomeAssistant,
integration_matchers: list[BluetoothMatcher],
scanning_mode: BluetoothScanningMode,
) -> None:
"""Init bluetooth discovery."""
self.hass = hass
self.scanning_mode = scanning_mode
self._integration_matchers = integration_matchers
self.scanner: HaBleakScanner | None = None
self._cancel_device_detected: CALLBACK_TYPE | None = None
self._callbacks: list[tuple[BluetoothCallback, BluetoothMatcher | None]] = []
# Some devices use a random address so we need to use
# an LRU to avoid memory issues.
self._matched: LRU = LRU(MAX_REMEMBER_ADDRESSES)

async def async_setup(self) -> None:
"""Set up BT Discovery."""
try:
self.scanner = HaBleakScanner(
scanning_mode=SCANNING_MODE_TO_BLEAK[self.scanning_mode]
)
except (FileNotFoundError, BleakError) as ex:
_LOGGER.warning(
"Could not create bluetooth scanner (is bluetooth present and enabled?): %s",
ex,
)
return
async_enable_rssi_updates()
install_multiple_bleak_catcher(self.scanner)
# We have to start it right away as some integrations might
# need it straight away.
_LOGGER.debug("Starting bluetooth scanner")
self.scanner.register_detection_callback(self.scanner.async_callback_dispatcher)
self._cancel_device_detected = self.scanner.async_register_callback(
self._device_detected, {}
)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
await self.scanner.start()

@hass_callback
def _device_detected(
self, device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
matched_domains: set[str] | None = None
if device.address not in self._matched:
matched_domains = {
matcher["domain"]
for matcher in self._integration_matchers
if _ble_device_matches(matcher, device, advertisement_data)
}
if matched_domains:
self._matched[device.address] = True
_LOGGER.debug(
"Device detected: %s with advertisement_data: %s matched domains: %s",
device,
advertisement_data,
matched_domains,
)

if not matched_domains and not self._callbacks:
return

service_info: BluetoothServiceInfo | None = None
for callback, matcher in self._callbacks:
if matcher is None or _ble_device_matches(
matcher, device, advertisement_data
):
if service_info is None:
service_info = BluetoothServiceInfo.from_advertisement(
device, advertisement_data
)
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")

if not matched_domains:
return
if service_info is None:
service_info = BluetoothServiceInfo.from_advertisement(
device, advertisement_data
)
for domain in matched_domains:
discovery_flow.async_create_flow(
self.hass,
domain,
{"source": config_entries.SOURCE_BLUETOOTH},
service_info,
)

@hass_callback
def async_register_callback(
self, callback: BluetoothCallback, match_dict: BluetoothMatcher | None = None
) -> Callable[[], None]:
"""Register a callback."""
callback_entry = (callback, match_dict)
self._callbacks.append(callback_entry)

@hass_callback
def _async_remove_callback() -> None:
self._callbacks.remove(callback_entry)

return _async_remove_callback

async def async_stop(self, event: Event) -> None:
"""Stop bluetooth discovery."""
if self._cancel_device_detected:
self._cancel_device_detected()
self._cancel_device_detected = None
if self.scanner:
await self.scanner.stop()
models.HA_BLEAK_SCANNER = None
3 changes: 3 additions & 0 deletions homeassistant/components/bluetooth/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Bluetooth integration."""

DOMAIN = "bluetooth"
10 changes: 10 additions & 0 deletions homeassistant/components/bluetooth/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "bluetooth",
"name": "Bluetooth",
"documentation": "https://www.home-assistant.io/integrations/bluetooth",
"dependencies": ["websocket_api"],
"quality_scale": "internal",
"requirements": ["bleak==0.14.3"],
"codeowners": ["@bdraco"],
"iot_class": "local_push"
}
Loading