A more effective logic to exclude noisy BLE beacons.

This includes BLE beacons sent from all Google/Apple/Microsoft/Samsung
beacon networks in all of their variants.
This commit is contained in:
Fabio Manganiello 2023-03-22 15:35:02 +01:00
parent 01d323fad0
commit 99cfd247a5
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774

View file

@ -1,11 +1,14 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from logging import getLogger
from queue import Queue from queue import Queue
from typing import Callable, Dict, Final, List, Optional, Type from typing import Callable, Collection, Dict, Final, List, Optional, Type
from uuid import UUID
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from platypush.entities.bluetooth import BluetoothDevice from platypush.context import get_bus
from platypush.entities.bluetooth import BluetoothDevice, BluetoothService
from platypush.message.event.bluetooth import ( from platypush.message.event.bluetooth import (
BluetoothDeviceConnectedEvent, BluetoothDeviceConnectedEvent,
BluetoothDeviceDisconnectedEvent, BluetoothDeviceDisconnectedEvent,
@ -14,18 +17,35 @@ from platypush.message.event.bluetooth import (
BluetoothDeviceEvent, BluetoothDeviceEvent,
) )
from platypush.context import get_bus
from .._cache import EntityCache from .._cache import EntityCache
from .._model import ServiceClass
from ._cache import DeviceCache from ._cache import DeviceCache
from ._mappers import device_to_entity from ._mappers import device_to_entity
_rssi_update_interval: Final[float] = 30.0 _rssi_update_interval: Final[float] = 30.0
""" How often to trigger RSSI update events for a device. """ """ How often to trigger RSSI update events for a device. """
_excluded_manufacturers: Final[Collection[str]] = {
'Apple, Inc.',
'GAEN',
'Google',
'Google Inc.',
'Microsoft',
'Samsung Electronics Co., Ltd',
}
"""
Exclude beacons from these device manufacturers by default (main offenders
when it comes to Bluetooth device space pollution).
"""
logger = getLogger(__name__)
def _has_changed( def _has_changed(
old: Optional[BluetoothDevice], new: BluetoothDevice, attr: str old: Optional[BluetoothDevice],
new: BluetoothDevice,
attr: str,
tolerance: Optional[float] = None,
) -> bool: ) -> bool:
""" """
Returns True if the given attribute has changed on the device. Returns True if the given attribute has changed on the device.
@ -35,6 +55,8 @@ def _has_changed(
old_value = getattr(old, attr) old_value = getattr(old, attr)
new_value = getattr(new, attr) new_value = getattr(new, attr)
if tolerance and (old_value is not None and new_value is not None):
return abs(new_value - old_value) < tolerance
return old_value != new_value return old_value != new_value
@ -60,13 +82,17 @@ event_matchers: Dict[
old, new, 'connected', True old, new, 'connected', True
), ),
BluetoothDeviceDisconnectedEvent: lambda old, new: old is not None BluetoothDeviceDisconnectedEvent: lambda old, new: old is not None
and old.connected and bool(old.connected)
and _has_been_set(old, new, 'connected', False), and _has_been_set(old, new, 'connected', False),
BluetoothDeviceFoundEvent: lambda old, new: old is None BluetoothDeviceFoundEvent: lambda old, new: old is None
or (old.reachable is False and new.reachable is True), or (old.reachable is False and new.reachable is True),
BluetoothDeviceSignalUpdateEvent: lambda old, new: ( BluetoothDeviceSignalUpdateEvent: lambda old, new: (
(new.rssi is not None or new.tx_power is not None) (new.rssi is not None or new.tx_power is not None)
and (_has_changed(old, new, 'rssi') or _has_changed(old, new, 'tx_power')) and (old and old.rssi)
and (
_has_changed(old, new, 'rssi', tolerance=5)
or _has_changed(old, new, 'tx_power', tolerance=5)
)
and ( and (
not (old and old.updated_at) not (old and old.updated_at)
or datetime.utcnow() - old.updated_at or datetime.utcnow() - old.updated_at
@ -88,15 +114,18 @@ class EventHandler:
device_queue: Queue, device_queue: Queue,
device_cache: DeviceCache, device_cache: DeviceCache,
entity_cache: EntityCache, entity_cache: EntityCache,
exclude_known_noisy_beacons: bool,
): ):
""" """
:param device_queue: Queue used to publish updated devices upstream. :param device_queue: Queue used to publish updated devices upstream.
:param device_cache: Device cache. :param device_cache: Device cache.
:param entity_cache: Entity cache. :param entity_cache: Entity cache.
:param exclude_known_noisy_beacons: Exclude known noisy beacons.
""" """
self._device_queue = device_queue self._device_queue = device_queue
self._device_cache = device_cache self._device_cache = device_cache
self._entity_cache = entity_cache self._entity_cache = entity_cache
self._exclude_known_noisy_beacons = exclude_known_noisy_beacons
def __call__(self, device: BLEDevice, data: AdvertisementData): def __call__(self, device: BLEDevice, data: AdvertisementData):
""" """
@ -117,10 +146,16 @@ class EventHandler:
:param data: The advertised data. :param data: The advertised data.
""" """
new_entity = device_to_entity(device, data)
if self._exclude_known_noisy_beacons and self._is_noisy_beacon(new_entity):
logger.info(
'exclude_known_noisy_beacons is set to True: skipping beacon from device %s',
device.address,
)
return
events: List[BluetoothDeviceEvent] = [] events: List[BluetoothDeviceEvent] = []
existing_entity = self._entity_cache.get(device.address) existing_entity = self._entity_cache.get(device.address)
new_entity = device_to_entity(device, data)
events += [ events += [
event_type.from_device(new_entity) event_type.from_device(new_entity)
for event_type, matcher in event_matchers.items() for event_type, matcher in event_matchers.items()
@ -133,3 +168,38 @@ class EventHandler:
if events: if events:
self._device_queue.put_nowait(new_entity) self._device_queue.put_nowait(new_entity)
@staticmethod
def _is_noisy_beacon(device: BluetoothDevice) -> bool:
"""
Check if the beacon received from the given device should be skipped.
"""
# "Noisy" beacon devices usually have no associated friendly name. If a
# device has a valid name, we should probably include it.
if (
device.name
and device.name.replace('-', ':').lower() != device.address.lower()
):
return False
# If the manufacturer is in the excluded list, we should skip it
if device.manufacturer in _excluded_manufacturers:
return True
# If the device has any children other than services, don't skip it
if any(not isinstance(child, BluetoothService) for child in device.children):
return False
mapped_uuids = [
int(str(srv.uuid).split('-')[0], 16) & 0xFFFF
if isinstance(srv.uuid, UUID)
else srv.uuid
for srv in device.services
]
# If any of the services matches the blacklisted manufacturers, skip
# the event.
return any(
str(ServiceClass.get(uuid)) in _excluded_manufacturers
for uuid in mapped_uuids
)