2022-07-24 21:39:53 +00:00
|
|
|
"""The bluetooth integration matchers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import fnmatch
|
2022-08-01 15:54:06 +00:00
|
|
|
from typing import TYPE_CHECKING, Final, TypedDict
|
2022-07-24 21:39:53 +00:00
|
|
|
|
|
|
|
from lru import LRU # pylint: disable=no-name-in-module
|
|
|
|
|
|
|
|
from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional
|
|
|
|
|
2022-08-22 18:02:26 +00:00
|
|
|
from .models import BluetoothServiceInfoBleak
|
|
|
|
|
2022-08-01 15:54:06 +00:00
|
|
|
if TYPE_CHECKING:
|
2022-08-05 12:49:34 +00:00
|
|
|
from collections.abc import MutableMapping
|
2022-08-01 15:54:06 +00:00
|
|
|
|
|
|
|
from bleak.backends.scanner import AdvertisementData
|
|
|
|
|
|
|
|
|
2022-07-24 21:39:53 +00:00
|
|
|
MAX_REMEMBER_ADDRESSES: Final = 2048
|
|
|
|
|
|
|
|
|
|
|
|
ADDRESS: Final = "address"
|
2022-08-22 18:02:26 +00:00
|
|
|
CONNECTABLE: Final = "connectable"
|
2022-07-24 21:39:53 +00:00
|
|
|
LOCAL_NAME: Final = "local_name"
|
|
|
|
SERVICE_UUID: Final = "service_uuid"
|
|
|
|
SERVICE_DATA_UUID: Final = "service_data_uuid"
|
|
|
|
MANUFACTURER_ID: Final = "manufacturer_id"
|
|
|
|
MANUFACTURER_DATA_START: Final = "manufacturer_data_start"
|
|
|
|
|
|
|
|
|
|
|
|
class BluetoothCallbackMatcherOptional(TypedDict, total=False):
|
|
|
|
"""Matcher for the bluetooth integration for callback optional fields."""
|
|
|
|
|
|
|
|
address: str
|
|
|
|
|
|
|
|
|
|
|
|
class BluetoothCallbackMatcher(
|
|
|
|
BluetoothMatcherOptional,
|
|
|
|
BluetoothCallbackMatcherOptional,
|
|
|
|
):
|
|
|
|
"""Callback matcher for the bluetooth integration."""
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=False)
|
|
|
|
class IntegrationMatchHistory:
|
|
|
|
"""Track which fields have been seen."""
|
|
|
|
|
|
|
|
manufacturer_data: bool
|
|
|
|
service_data: bool
|
|
|
|
service_uuids: bool
|
|
|
|
|
|
|
|
|
|
|
|
def seen_all_fields(
|
2022-08-22 18:02:26 +00:00
|
|
|
previous_match: IntegrationMatchHistory, advertisement_data: AdvertisementData
|
2022-07-24 21:39:53 +00:00
|
|
|
) -> bool:
|
|
|
|
"""Return if we have seen all fields."""
|
2022-08-22 18:02:26 +00:00
|
|
|
if not previous_match.manufacturer_data and advertisement_data.manufacturer_data:
|
2022-07-24 21:39:53 +00:00
|
|
|
return False
|
2022-08-22 18:02:26 +00:00
|
|
|
if not previous_match.service_data and advertisement_data.service_data:
|
2022-07-24 21:39:53 +00:00
|
|
|
return False
|
2022-08-22 18:02:26 +00:00
|
|
|
if not previous_match.service_uuids and advertisement_data.service_uuids:
|
2022-07-24 21:39:53 +00:00
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
class IntegrationMatcher:
|
|
|
|
"""Integration matcher for the bluetooth integration."""
|
|
|
|
|
|
|
|
def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None:
|
|
|
|
"""Initialize the matcher."""
|
|
|
|
self._integration_matchers = integration_matchers
|
|
|
|
# Some devices use a random address so we need to use
|
|
|
|
# an LRU to avoid memory issues.
|
2022-08-05 12:49:34 +00:00
|
|
|
self._matched: MutableMapping[str, IntegrationMatchHistory] = LRU(
|
2022-07-24 21:39:53 +00:00
|
|
|
MAX_REMEMBER_ADDRESSES
|
|
|
|
)
|
2022-08-22 18:02:26 +00:00
|
|
|
self._matched_connectable: MutableMapping[str, IntegrationMatchHistory] = LRU(
|
|
|
|
MAX_REMEMBER_ADDRESSES
|
|
|
|
)
|
2022-07-24 21:39:53 +00:00
|
|
|
|
2022-08-05 12:49:34 +00:00
|
|
|
def async_clear_address(self, address: str) -> None:
|
|
|
|
"""Clear the history matches for a set of domains."""
|
|
|
|
self._matched.pop(address, None)
|
2022-08-22 18:02:26 +00:00
|
|
|
self._matched_connectable.pop(address, None)
|
|
|
|
|
|
|
|
def _get_matched_by_type(
|
|
|
|
self, connectable: bool
|
|
|
|
) -> MutableMapping[str, IntegrationMatchHistory]:
|
|
|
|
"""Return the matches by type."""
|
|
|
|
return self._matched_connectable if connectable else self._matched
|
2022-08-05 12:49:34 +00:00
|
|
|
|
2022-08-22 18:02:26 +00:00
|
|
|
def match_domains(self, service_info: BluetoothServiceInfoBleak) -> set[str]:
|
2022-07-24 21:39:53 +00:00
|
|
|
"""Return the domains that are matched."""
|
2022-08-22 18:02:26 +00:00
|
|
|
device = service_info.device
|
|
|
|
advertisement_data = service_info.advertisement
|
|
|
|
matched = self._get_matched_by_type(service_info.connectable)
|
2022-07-24 21:39:53 +00:00
|
|
|
matched_domains: set[str] = set()
|
2022-08-22 18:02:26 +00:00
|
|
|
if (previous_match := matched.get(device.address)) and seen_all_fields(
|
|
|
|
previous_match, advertisement_data
|
2022-07-24 21:39:53 +00:00
|
|
|
):
|
|
|
|
# We have seen all fields so we can skip the rest of the matchers
|
|
|
|
return matched_domains
|
|
|
|
matched_domains = {
|
|
|
|
matcher["domain"]
|
|
|
|
for matcher in self._integration_matchers
|
2022-08-22 18:02:26 +00:00
|
|
|
if ble_device_matches(matcher, service_info)
|
2022-07-24 21:39:53 +00:00
|
|
|
}
|
|
|
|
if not matched_domains:
|
|
|
|
return matched_domains
|
|
|
|
if previous_match:
|
2022-08-22 18:02:26 +00:00
|
|
|
previous_match.manufacturer_data |= bool(
|
|
|
|
advertisement_data.manufacturer_data
|
|
|
|
)
|
|
|
|
previous_match.service_data |= bool(advertisement_data.service_data)
|
|
|
|
previous_match.service_uuids |= bool(advertisement_data.service_uuids)
|
2022-07-24 21:39:53 +00:00
|
|
|
else:
|
2022-08-22 18:02:26 +00:00
|
|
|
matched[device.address] = IntegrationMatchHistory(
|
|
|
|
manufacturer_data=bool(advertisement_data.manufacturer_data),
|
|
|
|
service_data=bool(advertisement_data.service_data),
|
|
|
|
service_uuids=bool(advertisement_data.service_uuids),
|
2022-07-24 21:39:53 +00:00
|
|
|
)
|
|
|
|
return matched_domains
|
|
|
|
|
|
|
|
|
|
|
|
def ble_device_matches(
|
|
|
|
matcher: BluetoothCallbackMatcher | BluetoothMatcher,
|
2022-08-22 18:02:26 +00:00
|
|
|
service_info: BluetoothServiceInfoBleak,
|
2022-07-24 21:39:53 +00:00
|
|
|
) -> bool:
|
|
|
|
"""Check if a ble device and advertisement_data matches the matcher."""
|
2022-08-22 18:02:26 +00:00
|
|
|
device = service_info.device
|
2022-07-24 21:39:53 +00:00
|
|
|
if (address := matcher.get(ADDRESS)) is not None and device.address != address:
|
|
|
|
return False
|
|
|
|
|
2022-08-22 18:02:26 +00:00
|
|
|
if matcher.get(CONNECTABLE, True) and not service_info.connectable:
|
|
|
|
return False
|
|
|
|
|
|
|
|
advertisement_data = service_info.advertisement
|
2022-07-24 21:39:53 +00:00
|
|
|
if (local_name := matcher.get(LOCAL_NAME)) is not None and not fnmatch.fnmatch(
|
2022-08-22 18:02:26 +00:00
|
|
|
advertisement_data.local_name or device.name or device.address,
|
2022-07-24 21:39:53 +00:00
|
|
|
local_name,
|
|
|
|
):
|
|
|
|
return False
|
|
|
|
|
|
|
|
if (
|
|
|
|
service_uuid := matcher.get(SERVICE_UUID)
|
2022-08-22 18:02:26 +00:00
|
|
|
) is not None and service_uuid not in advertisement_data.service_uuids:
|
2022-07-24 21:39:53 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
if (
|
|
|
|
service_data_uuid := matcher.get(SERVICE_DATA_UUID)
|
2022-08-22 18:02:26 +00:00
|
|
|
) is not None and service_data_uuid not in advertisement_data.service_data:
|
2022-07-24 21:39:53 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
if (
|
|
|
|
manfacturer_id := matcher.get(MANUFACTURER_ID)
|
2022-08-22 18:02:26 +00:00
|
|
|
) is not None and manfacturer_id not in advertisement_data.manufacturer_data:
|
2022-07-24 21:39:53 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
if (manufacturer_data_start := matcher.get(MANUFACTURER_DATA_START)) is not None:
|
|
|
|
manufacturer_data_start_bytes = bytearray(manufacturer_data_start)
|
|
|
|
if not any(
|
|
|
|
manufacturer_data.startswith(manufacturer_data_start_bytes)
|
2022-08-22 18:02:26 +00:00
|
|
|
for manufacturer_data in advertisement_data.manufacturer_data.values()
|
2022-07-24 21:39:53 +00:00
|
|
|
):
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|