From d6805a8b1894f8ff739429bf45a016d537dbcbb2 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 23 Mar 2023 17:10:37 +0100 Subject: [PATCH] Added support for custom Bluetooth device plugins. --- .../plugins/bluetooth/_ble/_event_handler.py | 11 ++++- platypush/plugins/bluetooth/_ble/_mappers.py | 2 +- .../bluetooth/_ble/_plugins/__init__.py | 0 platypush/plugins/bluetooth/_manager.py | 8 ++++ .../plugins/bluetooth/_plugins/__init__.py | 4 ++ platypush/plugins/bluetooth/_plugins/_base.py | 39 +++++++++++++++++ .../plugins/bluetooth/_plugins/_scanner.py | 42 +++++++++++++++++++ 7 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 platypush/plugins/bluetooth/_ble/_plugins/__init__.py create mode 100644 platypush/plugins/bluetooth/_plugins/__init__.py create mode 100644 platypush/plugins/bluetooth/_plugins/_base.py create mode 100644 platypush/plugins/bluetooth/_plugins/_scanner.py diff --git a/platypush/plugins/bluetooth/_ble/_event_handler.py b/platypush/plugins/bluetooth/_ble/_event_handler.py index 7b9f6794..fc1125fb 100644 --- a/platypush/plugins/bluetooth/_ble/_event_handler.py +++ b/platypush/plugins/bluetooth/_ble/_event_handler.py @@ -19,6 +19,7 @@ from platypush.message.event.bluetooth import ( from .._cache import EntityCache from .._model import ServiceClass +from .._plugins import BaseBluetoothPlugin from ._cache import DeviceCache from ._mappers import device_to_entity @@ -89,7 +90,7 @@ event_matchers: Dict[ or (old.reachable is False and new.reachable is True), BluetoothDeviceSignalUpdateEvent: lambda old, new: ( (new.rssi is not None or new.tx_power is not None) - and (old and old.rssi) + and bool(old and old.rssi) and ( _has_changed(old, new, 'rssi', tolerance=5) or _has_changed(old, new, 'tx_power', tolerance=5) @@ -115,6 +116,7 @@ class EventHandler: device_queue: Queue, device_cache: DeviceCache, entity_cache: EntityCache, + plugins: Collection[BaseBluetoothPlugin], exclude_known_noisy_beacons: bool, ): """ @@ -126,6 +128,7 @@ class EventHandler: self._device_queue = device_queue self._device_cache = device_cache self._entity_cache = entity_cache + self._plugins = plugins self._exclude_known_noisy_beacons = exclude_known_noisy_beacons def __call__(self, device: BLEDevice, data: AdvertisementData): @@ -155,6 +158,10 @@ class EventHandler: ) return + # Extend the new entity with children entities added by the plugins + for plugin in self._plugins: + plugin.extend_device(new_entity) + events: List[BluetoothDeviceEvent] = [] existing_entity = self._entity_cache.get(device.address) events += [ @@ -195,7 +202,7 @@ class EventHandler: return False mapped_uuids = [ - int(str(srv.uuid).split('-')[0], 16) & 0xFFFF + int(str(srv.uuid).split('-', maxsplit=1)[0], 16) & 0xFFFF if isinstance(srv.uuid, UUID) else srv.uuid for srv in device.services diff --git a/platypush/plugins/bluetooth/_ble/_mappers.py b/platypush/plugins/bluetooth/_ble/_mappers.py index de227d8b..0eda4570 100644 --- a/platypush/plugins/bluetooth/_ble/_mappers.py +++ b/platypush/plugins/bluetooth/_ble/_mappers.py @@ -210,7 +210,7 @@ def _parse_services( srv_cls = ServiceClass.get(uuid) services.append( BluetoothService( - id=f'{device.address}:{uuid}', + id=f'{device.address}::{uuid}', uuid=uuid, name=f'[{uuid}]' if srv_cls == ServiceClass.UNKNOWN else str(srv_cls), protocol=Protocol.L2CAP, diff --git a/platypush/plugins/bluetooth/_ble/_plugins/__init__.py b/platypush/plugins/bluetooth/_ble/_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/platypush/plugins/bluetooth/_manager.py b/platypush/plugins/bluetooth/_manager.py index 7c1e4689..5941315e 100644 --- a/platypush/plugins/bluetooth/_manager.py +++ b/platypush/plugins/bluetooth/_manager.py @@ -43,6 +43,8 @@ class BaseBluetoothManager(ABC, threading.Thread): :param device_cache: Cache used to keep track of discovered devices. :param exclude_known_noisy_beacons: Exclude known noisy beacons. """ + from ._plugins import scan_plugins + kwargs['name'] = f'Bluetooth:{self.__class__.__name__}' super().__init__(**kwargs) @@ -59,6 +61,8 @@ class BaseBluetoothManager(ABC, threading.Thread): self._cache = device_cache or EntityCache() """ Cache of discovered devices. """ + self._plugins = scan_plugins(self) + """ Plugins compatible with this manager. """ def notify( self, event_type: Type[BluetoothDeviceEvent], device: BluetoothDevice, **kwargs @@ -71,6 +75,10 @@ class BaseBluetoothManager(ABC, threading.Thread): get_bus().post(event_type.from_device(device=device, **kwargs)) self._device_queue.put_nowait(device) + @property + def plugins(self): + return self._plugins + def should_stop(self) -> bool: return self._stop_event.is_set() diff --git a/platypush/plugins/bluetooth/_plugins/__init__.py b/platypush/plugins/bluetooth/_plugins/__init__.py new file mode 100644 index 00000000..ff7e2dc2 --- /dev/null +++ b/platypush/plugins/bluetooth/_plugins/__init__.py @@ -0,0 +1,4 @@ +from ._base import BaseBluetoothPlugin +from ._scanner import scan_plugins + +__all__ = ['BaseBluetoothPlugin', 'scan_plugins'] diff --git a/platypush/plugins/bluetooth/_plugins/_base.py b/platypush/plugins/bluetooth/_plugins/_base.py new file mode 100644 index 00000000..51b03eee --- /dev/null +++ b/platypush/plugins/bluetooth/_plugins/_base.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod +from typing import Iterable + +from platypush.entities import Entity +from platypush.entities.bluetooth import BluetoothDevice +from platypush.plugins.bluetooth._manager import BaseBluetoothManager + + +class BaseBluetoothPlugin(ABC): + """ + Base class for Bluetooth plugins, like Switchbot or HID integrations. + """ + + def __init__(self, manager: BaseBluetoothManager): + self._manager = manager + + @abstractmethod + def supports_device(self, device: BluetoothDevice) -> bool: + """ + Returns True if the given device matches this plugin. + """ + + @abstractmethod + def _extract_entities(self, device: BluetoothDevice) -> Iterable[Entity]: + """ + If :meth:`_matches_device` returns True, this method should return an + iterable of entities that will be used to extend the existing device - + like sensors, switches etc. + """ + + def extend_device(self, device: BluetoothDevice): + """ + Extends the given device with entities extracted through + :meth:`_extract_entities`, if :meth:`_matches_device` returns True. + """ + if not self.supports_device(device): + return + + device.children.extend(self._extract_entities(device)) diff --git a/platypush/plugins/bluetooth/_plugins/_scanner.py b/platypush/plugins/bluetooth/_plugins/_scanner.py new file mode 100644 index 00000000..b078e97f --- /dev/null +++ b/platypush/plugins/bluetooth/_plugins/_scanner.py @@ -0,0 +1,42 @@ +import importlib +import inspect +import logging +import os +import pkgutil +from typing import List + +from platypush.plugins.bluetooth._plugins import BaseBluetoothPlugin + +logger = logging.getLogger(__name__) + + +def scan_plugins(manager) -> List[BaseBluetoothPlugin]: + """ + Initializes all the plugins associated to the given BluetoothManager by + scanning all the modules under the ``_plugins`` folder in the manager's + package. + """ + plugins = {} + base_dir = os.path.dirname(inspect.getfile(manager.__class__)) + module = inspect.getmodule(manager.__class__) + assert module is not None + package = module.__package__ + assert package is not None + + for _, mod_name, _ in pkgutil.walk_packages([base_dir], prefix=package + '.'): + try: + module = importlib.import_module(mod_name) + except Exception as e: + logger.warning('Could not import module %s', mod_name) + logger.exception(e) + continue + + for _, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and not inspect.isabstract(obj) + and issubclass(obj, BaseBluetoothPlugin) + ): + plugins[obj] = obj(manager) + + return list(plugins.values())