#94138 added support for raw/bundled advertisements. We should use the same monotonic time for all advertisements in the bundle if not time is passed, or calculate the timestamp and pass it if its known
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""The bluetooth integration."""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
import platform
|
|
from typing import TYPE_CHECKING
|
|
|
|
from bleak_retry_connector import BleakSlotManager
|
|
from bluetooth_adapters import (
|
|
ADAPTER_ADDRESS,
|
|
ADAPTER_CONNECTION_SLOTS,
|
|
ADAPTER_HW_VERSION,
|
|
ADAPTER_MANUFACTURER,
|
|
ADAPTER_SW_VERSION,
|
|
DEFAULT_ADDRESS,
|
|
DEFAULT_CONNECTION_SLOTS,
|
|
AdapterDetails,
|
|
adapter_human_name,
|
|
adapter_model,
|
|
adapter_unique_name,
|
|
get_adapters,
|
|
)
|
|
from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak
|
|
|
|
from homeassistant.components import usb
|
|
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
|
from homeassistant.core import Event, HassJob, HomeAssistant, callback as hass_callback
|
|
from homeassistant.exceptions import ConfigEntryNotReady
|
|
from homeassistant.helpers import (
|
|
config_validation as cv,
|
|
device_registry as dr,
|
|
discovery_flow,
|
|
)
|
|
from homeassistant.helpers.debounce import Debouncer
|
|
from homeassistant.helpers.event import async_call_later
|
|
from homeassistant.helpers.issue_registry import async_delete_issue
|
|
from homeassistant.loader import async_get_bluetooth
|
|
|
|
from . import models
|
|
from .api import (
|
|
_get_manager,
|
|
async_address_present,
|
|
async_ble_device_from_address,
|
|
async_discovered_service_info,
|
|
async_get_advertisement_callback,
|
|
async_get_scanner,
|
|
async_last_service_info,
|
|
async_process_advertisements,
|
|
async_rediscover_address,
|
|
async_register_callback,
|
|
async_register_scanner,
|
|
async_scanner_by_source,
|
|
async_scanner_count,
|
|
async_scanner_devices_by_address,
|
|
async_track_unavailable,
|
|
)
|
|
from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice
|
|
from .const import (
|
|
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
|
CONF_ADAPTER,
|
|
CONF_DETAILS,
|
|
CONF_PASSIVE,
|
|
DATA_MANAGER,
|
|
DOMAIN,
|
|
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
|
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
|
|
SOURCE_LOCAL,
|
|
)
|
|
from .manager import BluetoothManager
|
|
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
|
from .models import (
|
|
BluetoothCallback,
|
|
BluetoothChange,
|
|
BluetoothScanningMode,
|
|
HaBluetoothConnector,
|
|
)
|
|
from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError
|
|
from .storage import BluetoothStorage
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
__all__ = [
|
|
"async_address_present",
|
|
"async_ble_device_from_address",
|
|
"async_discovered_service_info",
|
|
"async_get_scanner",
|
|
"async_last_service_info",
|
|
"async_process_advertisements",
|
|
"async_rediscover_address",
|
|
"async_register_callback",
|
|
"async_register_scanner",
|
|
"async_track_unavailable",
|
|
"async_scanner_by_source",
|
|
"async_scanner_count",
|
|
"async_scanner_devices_by_address",
|
|
"BaseHaScanner",
|
|
"BaseHaRemoteScanner",
|
|
"BluetoothCallbackMatcher",
|
|
"BluetoothChange",
|
|
"BluetoothServiceInfo",
|
|
"BluetoothServiceInfoBleak",
|
|
"BluetoothScanningMode",
|
|
"BluetoothCallback",
|
|
"BluetoothScannerDevice",
|
|
"HaBluetoothConnector",
|
|
"SOURCE_LOCAL",
|
|
"FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
|
|
"MONOTONIC_TIME",
|
|
]
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
|
|
|
|
|
async def _async_get_adapter_from_address(
|
|
hass: HomeAssistant, address: str
|
|
) -> str | None:
|
|
"""Get an adapter by the address."""
|
|
return await _get_manager(hass).async_get_adapter_from_address(address)
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
"""Set up the bluetooth integration."""
|
|
integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass))
|
|
integration_matcher.async_setup()
|
|
bluetooth_adapters = get_adapters()
|
|
bluetooth_storage = BluetoothStorage(hass)
|
|
await bluetooth_storage.async_setup()
|
|
slot_manager = BleakSlotManager()
|
|
await slot_manager.async_setup()
|
|
manager = BluetoothManager(
|
|
hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager
|
|
)
|
|
await manager.async_setup()
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop)
|
|
hass.data[DATA_MANAGER] = models.MANAGER = manager
|
|
adapters = await manager.async_get_bluetooth_adapters()
|
|
|
|
async_migrate_entries(hass, adapters, bluetooth_adapters.default_adapter)
|
|
await async_discover_adapters(hass, adapters)
|
|
|
|
async def _async_rediscover_adapters() -> None:
|
|
"""Rediscover adapters when a new one may be available."""
|
|
discovered_adapters = await manager.async_get_bluetooth_adapters(cached=False)
|
|
_LOGGER.debug("Rediscovered adapters: %s", discovered_adapters)
|
|
await async_discover_adapters(hass, discovered_adapters)
|
|
|
|
discovery_debouncer = Debouncer(
|
|
hass,
|
|
_LOGGER,
|
|
cooldown=BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
|
immediate=False,
|
|
function=_async_rediscover_adapters,
|
|
)
|
|
|
|
async def _async_shutdown_debouncer(_: Event) -> None:
|
|
"""Shutdown debouncer."""
|
|
await discovery_debouncer.async_shutdown()
|
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_debouncer)
|
|
|
|
async def _async_call_debouncer(now: datetime.datetime) -> None:
|
|
"""Call the debouncer at a later time."""
|
|
await discovery_debouncer.async_call()
|
|
|
|
call_debouncer_job = HassJob(_async_call_debouncer, cancel_on_shutdown=True)
|
|
|
|
def _async_trigger_discovery() -> None:
|
|
# There are so many bluetooth adapter models that
|
|
# we check the bus whenever a usb device is plugged in
|
|
# to see if it is a bluetooth adapter since we can't
|
|
# tell if the device is a bluetooth adapter or if its
|
|
# actually supported unless we ask DBus if its now
|
|
# present.
|
|
_LOGGER.debug("Triggering bluetooth usb discovery")
|
|
hass.async_create_task(discovery_debouncer.async_call())
|
|
# Because it can take 120s for the firmware loader
|
|
# fallback to timeout we need to wait that plus
|
|
# the debounce time to ensure we do not miss the
|
|
# adapter becoming available to DBus since otherwise
|
|
# we will never see the new adapter until
|
|
# Home Assistant is restarted
|
|
async_call_later(
|
|
hass,
|
|
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS + LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
|
|
call_debouncer_job,
|
|
)
|
|
|
|
cancel = usb.async_register_scan_request_callback(hass, _async_trigger_discovery)
|
|
hass.bus.async_listen_once(
|
|
EVENT_HOMEASSISTANT_STOP, hass_callback(lambda event: cancel())
|
|
)
|
|
|
|
async_delete_issue(hass, DOMAIN, "haos_outdated")
|
|
return True
|
|
|
|
|
|
@hass_callback
|
|
def async_migrate_entries(
|
|
hass: HomeAssistant, adapters: dict[str, AdapterDetails], default_adapter: str
|
|
) -> None:
|
|
"""Migrate config entries to support multiple."""
|
|
current_entries = hass.config_entries.async_entries(DOMAIN)
|
|
|
|
for entry in current_entries:
|
|
if entry.unique_id:
|
|
continue
|
|
|
|
address = DEFAULT_ADDRESS
|
|
adapter = entry.options.get(CONF_ADAPTER, default_adapter)
|
|
if adapter in adapters:
|
|
address = adapters[adapter][ADAPTER_ADDRESS]
|
|
hass.config_entries.async_update_entry(
|
|
entry, title=adapter_unique_name(adapter, address), unique_id=address
|
|
)
|
|
|
|
|
|
async def async_discover_adapters(
|
|
hass: HomeAssistant,
|
|
adapters: dict[str, AdapterDetails],
|
|
) -> None:
|
|
"""Discover adapters and start flows."""
|
|
if platform.system() == "Windows":
|
|
# We currently do not have a good way to detect if a bluetooth device is
|
|
# available on Windows. We will just assume that it is not unless they
|
|
# actively add it.
|
|
return
|
|
|
|
for adapter, details in adapters.items():
|
|
discovery_flow.async_create_flow(
|
|
hass,
|
|
DOMAIN,
|
|
context={"source": SOURCE_INTEGRATION_DISCOVERY},
|
|
data={CONF_ADAPTER: adapter, CONF_DETAILS: details},
|
|
)
|
|
|
|
|
|
async def async_update_device(
|
|
hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
|
|
) -> None:
|
|
"""Update device registry entry.
|
|
|
|
The physical adapter can change from hci0/hci1 on reboot
|
|
or if the user moves around the usb sticks so we need to
|
|
update the device with the new location so they can
|
|
figure out where the adapter is.
|
|
"""
|
|
dr.async_get(hass).async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
|
|
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
|
|
manufacturer=details[ADAPTER_MANUFACTURER],
|
|
model=adapter_model(details),
|
|
sw_version=details.get(ADAPTER_SW_VERSION),
|
|
hw_version=details.get(ADAPTER_HW_VERSION),
|
|
)
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Set up a config entry for a bluetooth scanner."""
|
|
address = entry.unique_id
|
|
assert address is not None
|
|
adapter = await _async_get_adapter_from_address(hass, address)
|
|
if adapter is None:
|
|
raise ConfigEntryNotReady(
|
|
f"Bluetooth adapter {adapter} with address {address} not found"
|
|
)
|
|
|
|
passive = entry.options.get(CONF_PASSIVE)
|
|
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
|
|
new_info_callback = async_get_advertisement_callback(hass)
|
|
manager: BluetoothManager = hass.data[DATA_MANAGER]
|
|
scanner = HaScanner(hass, mode, adapter, address, new_info_callback)
|
|
try:
|
|
scanner.async_setup()
|
|
except RuntimeError as err:
|
|
raise ConfigEntryNotReady(
|
|
f"{adapter_human_name(adapter, address)}: {err}"
|
|
) from err
|
|
try:
|
|
await scanner.async_start()
|
|
except ScannerStartError as err:
|
|
raise ConfigEntryNotReady from err
|
|
adapters = await manager.async_get_bluetooth_adapters()
|
|
details = adapters[adapter]
|
|
slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS
|
|
entry.async_on_unload(async_register_scanner(hass, scanner, True, slots))
|
|
await async_update_device(hass, entry, adapter, details)
|
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner
|
|
entry.async_on_unload(entry.add_update_listener(async_update_listener))
|
|
return True
|
|
|
|
|
|
async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Handle options update."""
|
|
await hass.config_entries.async_reload(entry.entry_id)
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
scanner: HaScanner = hass.data[DOMAIN].pop(entry.entry_id)
|
|
await scanner.async_stop()
|
|
return True
|