Compare commits

...

22 Commits

Author SHA1 Message Date
Fabio Manganiello 2411b961e8
[WIP] Big, big refactor of the Bluetooth integration.
- Merged together Bluetooth legacy and BLE plugins and scanners.
- Introduced Theengs as a dependency to infer BLE device types and
  create sub-entities appropriately.
- Using `BluetoothDevice` and `BluetoothService` entities as the bread
  and butter for all the Bluetooth plugin's components.
- Using a shared cache of devices and services between the legacy and
  BLE integrations, with merging/coalescing logic included.
- Extended list of discoverable services to include all those officially
  supported by the Bluetooth specs.
- Instantiate a separate pool of workers to discover services.
- Refactor of the Bluetooth events - all of them are now instantiated
  from a single `BluetoothDevice` object.
2023-03-13 02:31:21 +01:00
Fabio Manganiello 4bc61133c5
The Entity object should also have a `to_json` method. 2023-03-12 23:01:51 +01:00
Fabio Manganiello 4a8da80c7c
Don't join self._thread on stop in RunnablePlugin if self._thread = current_thread 2023-03-11 23:45:46 +01:00
Fabio Manganiello 31552963c4
`EntitiesDb.upsert` should return a deep copy of the upserted entities.
Not the upserted entities themselves, no matter if expunged or made transient.

Reminder to my future self: returning the flushed entities and then using them
outside of the session or in another thread opens a big can of worms when using
SQLAlchemy.
2023-03-10 12:06:36 +01:00
Fabio Manganiello f45e47363d
Use lazy='joined' instead of lazy='selectin' on Entity.parent.
That's the best way to ensure that all the columns are fetched eagerly and
prevent errors later when trying to access lazily loaded attributes outside
of the session/thread.
2023-03-10 12:01:23 +01:00
Fabio Manganiello 8ccf3e804d
Added utility get_entities_engine() function. 2023-03-10 11:49:23 +01:00
Fabio Manganiello 60da930e4b
Added support for get_plugin(MyPlugin) besides get_plugin('my'). 2023-03-10 11:47:39 +01:00
Fabio Manganiello 3fcc9957d1
A dirty fix to prevent DetachedInstanceError when accessing the parent entity. 2023-03-10 11:45:44 +01:00
Fabio Manganiello ceb7a2f098
EntitiesEngine synchronization improvements.
- Added `wait_start()` method that other threads can use to synchronize
  with the engine and wait before performing db operations.

- Callback logic wrapped in a try/except block to prevent custom
  integrations with buggy callbacks from crashing the engine.
2023-03-10 00:57:24 +01:00
Fabio Manganiello 73dc2463f1
Added auto_commit=False to entities.save() 2023-03-10 00:40:51 +01:00
Fabio Manganiello 7e92d5f244
Added recursive `.copy()` method to `Entity`. 2023-03-09 22:35:31 +01:00
Fabio Manganiello 9f9ee575f1
Added _import_error_ignored_modules.
ImportErrors on these entity modules will be ignored when dynamically
loading them, since they have optional external dependencies and we
shouldn't throw an error if we can't import them.
2023-03-09 01:40:35 +01:00
Fabio Manganiello c4efec6832
Several fixes and improvements on the entities engine.
- Support for an optional callback on `publish_entities` to get notified
  when the published object are flushed to the db.

- Use `lazy='selectin'` for the entity parent -> children relationship -
  it is more efficient and it ensures that all the data the application
  needs is loaded upfront.

- `Entity.entity_key` rolled back to `<external_id, plugin>`. The
  fallback logic on `<id, plugin>` created more problems than those it
  as supposed to solve.

- Added `expire_on_commit=False` to the entities engine session to make
  sure that we don't get errors on detached/expired instances.
2023-03-09 01:16:04 +01:00
Fabio Manganiello 1781a19a79
s/Entity.to_json/Entity.to_dict/g
stuff
2023-03-06 23:46:33 +01:00
Fabio Manganiello b9efa9fa30
entity_key should coalesce (entity.external_id or entity.id) 2023-03-06 16:54:02 +01:00
Fabio Manganiello 72c55c03f2
[WIP] Refactoring/extending models and parsers for Bluetooth entities. 2023-03-03 02:10:11 +01:00
Fabio Manganiello a688e7102e
Changed default `poll_interval` for `RunnablePlugin`.
30 -> 15 seconds.
2023-03-03 02:00:48 +01:00
Fabio Manganiello ead4513915
Added optional `unit` column to `RawSensor` entity. 2023-03-03 01:59:27 +01:00
Fabio Manganiello 94c4e52154
Mock PyOBEX.client in readthedocs conf.py 2023-03-03 01:58:32 +01:00
Fabio Manganiello 7be55e446f
Convert UUID objects to strings when serializing to JSON. 2023-03-02 21:58:26 +01:00
Fabio Manganiello 15fadb93bb
Added stand-alone `connect` and `disconnect` actions to `bluetooth`. 2023-02-25 01:59:35 +01:00
Fabio Manganiello 70d1bb893c
A cleaner way of calculating the `success` response attribute. 2023-02-25 01:58:09 +01:00
58 changed files with 3621 additions and 1502 deletions

View File

@ -249,6 +249,7 @@ autodoc_mock_imports = [
'pyaudio',
'avs',
'PyOBEX',
'PyOBEX.client',
'todoist',
'trello',
'telegram',

View File

@ -8,7 +8,7 @@ from typing import Optional, Any
from ..bus import Bus
from ..config import Config
from ..utils import get_enabled_plugins
from ..utils import get_enabled_plugins, get_plugin_name_by_class
logger = logging.getLogger('platypush:context')
@ -108,32 +108,50 @@ def get_backend(name):
return _ctx.backends.get(name)
def get_plugin(plugin_name, reload=False):
# pylint: disable=too-many-branches
def get_plugin(plugin, plugin_name=None, reload=False):
"""
Registers a plugin instance by name if not registered already, or returns
the registered plugin instance.
:param plugin: Plugin name or class type.
:param plugin_name: Plugin name, kept only for backwards compatibility.
:param reload: If ``True``, the plugin will be reloaded if it's already
been registered.
"""
if plugin_name not in plugins_init_locks:
plugins_init_locks[plugin_name] = RLock()
from ..plugins import Plugin
if plugin_name in _ctx.plugins and not reload:
return _ctx.plugins[plugin_name]
if isinstance(plugin, str):
name = plugin
elif plugin_name:
name = plugin_name
elif issubclass(plugin, Plugin):
name = get_plugin_name_by_class(plugin) # type: ignore
else:
raise TypeError(f'Invalid plugin type/name: {plugin}')
try:
plugin = importlib.import_module('platypush.plugins.' + plugin_name)
except ImportError as e:
logger.warning('No such plugin: %s', plugin_name)
raise RuntimeError(e) from e
if name not in plugins_init_locks:
plugins_init_locks[name] = RLock()
if name in _ctx.plugins and not reload:
return _ctx.plugins[name]
if isinstance(plugin, str):
try:
plugin = importlib.import_module(
'platypush.plugins.' + name
) # type: ignore
except ImportError as e:
logger.warning('No such plugin: %s', name)
raise RuntimeError(e) from e
# e.g. plugins.music.mpd main class: MusicMpdPlugin
cls_name = ''
for token in plugin_name.split('.'):
for token in name.split('.'):
cls_name += token.title()
cls_name += 'Plugin'
plugin_conf = (
Config.get_plugins()[plugin_name] if plugin_name in Config.get_plugins() else {}
)
plugin_conf = Config.get_plugins()[name] if name in Config.get_plugins() else {}
if 'disabled' in plugin_conf:
if plugin_conf['disabled'] is True:
@ -148,15 +166,15 @@ def get_plugin(plugin_name, reload=False):
try:
plugin_class = getattr(plugin, cls_name)
except AttributeError as e:
logger.warning('No such class in %s: %s [error: %s]', plugin_name, cls_name, e)
logger.warning('No such class in %s: %s [error: %s]', name, cls_name, e)
raise RuntimeError(e) from e
with plugins_init_locks[plugin_name]:
if _ctx.plugins.get(plugin_name) and not reload:
return _ctx.plugins[plugin_name]
_ctx.plugins[plugin_name] = plugin_class(**plugin_conf)
with plugins_init_locks[name]:
if _ctx.plugins.get(name) and not reload:
return _ctx.plugins[name]
_ctx.plugins[name] = plugin_class(**plugin_conf)
return _ctx.plugins[plugin_name]
return _ctx.plugins[name]
def get_bus() -> Bus:

View File

@ -1,7 +1,14 @@
from datetime import datetime, timedelta
import logging
from threading import Event
from typing import Collection, Optional
from ._base import Entity, get_entities_registry, init_entities_db
from ._base import (
Entity,
EntitySavedCallback,
get_entities_registry,
init_entities_db,
)
from ._engine import EntitiesEngine
from ._managers import (
EntityManager,
@ -31,7 +38,26 @@ def init_entities_engine() -> EntitiesEngine:
return _engine
def publish_entities(entities: Collection[Entity]):
def get_entities_engine(timeout: Optional[float] = None) -> EntitiesEngine:
"""
Return the running entities engine.
:param timeout: Timeout in seconds (default: None).
"""
time_start = datetime.utcnow()
while not timeout or (datetime.utcnow() - time_start < timedelta(seconds=timeout)):
if _engine:
break
Event().wait(1)
assert _engine, 'The entities engine has not been initialized'
return _engine
def publish_entities(
entities: Collection[Entity], callback: Optional[EntitySavedCallback] = None
) -> None:
"""
Publish a collection of entities to be processed by the engine.
@ -47,7 +73,7 @@ def publish_entities(entities: Collection[Entity]):
logger.debug('No entities engine registered')
return
_engine.post(*entities)
_engine.post(*entities, callback=callback)
__all__ = (
@ -55,6 +81,7 @@ __all__ = (
'EntitiesEngine',
'Entity',
'EntityManager',
'EntitySavedCallback',
'EnumSwitchEntityManager',
'LightEntityManager',
'SensorEntityManager',

View File

@ -3,10 +3,10 @@ import json
import pathlib
import types
from datetime import datetime
from dateutil.tz import tzutc
from typing import Mapping, Type, Tuple, Any
import pkgutil
from typing import Callable, Dict, Final, Set, Type, Tuple, Any
from dateutil.tz import tzutc
from sqlalchemy import (
Boolean,
Column,
@ -24,7 +24,16 @@ from sqlalchemy.orm import ColumnProperty, Mapped, backref, relationship
from platypush.common.db import Base
from platypush.message import JSONAble
entities_registry: Mapping[Type['Entity'], Mapping] = {}
EntityRegistryType = Dict[str, Type['Entity']]
entities_registry: EntityRegistryType = {}
_import_error_ignored_modules: Final[Set[str]] = {'bluetooth'}
"""
ImportError exceptions will be ignored for these entity submodules when
imported dynamically. An ImportError for these modules means that some optional
requirements are missing, and if those plugins aren't enabled then we shouldn't
fail.
"""
if 'entity' not in Base.metadata:
@ -70,7 +79,7 @@ if 'entity' not in Base.metadata:
'Entity',
remote_side=[id],
uselist=False,
lazy=True,
lazy='joined',
post_update=True,
backref=backref(
'children',
@ -94,9 +103,9 @@ if 'entity' not in Base.metadata:
'polymorphic_on': type,
}
@classmethod
@classmethod # type: ignore
@property
def columns(cls) -> Tuple[ColumnProperty]:
def columns(cls) -> Tuple[ColumnProperty, ...]:
inspector = schema_inspect(cls)
return tuple(inspector.mapper.column_attrs)
@ -105,7 +114,18 @@ if 'entity' not in Base.metadata:
"""
This method returns the "external" key of an entity.
"""
return (str(self.external_id), str(self.plugin))
return str(self.external_id), str(self.plugin)
def copy(self) -> 'Entity':
"""
This method returns a copy of the entity. It's useful when you want
to reuse entity objects in other threads or outside of their
associated SQLAlchemy session context.
"""
return self.__class__(
**{col.key: getattr(self, col.key, None) for col in self.columns},
children=[child.copy() for child in self.children],
)
def _serialize_value(self, col: ColumnProperty) -> Any:
val = getattr(self, col.key)
@ -115,16 +135,31 @@ if 'entity' not in Base.metadata:
return val
def to_json(self) -> dict:
def to_dict(self) -> dict:
return {col.key: self._serialize_value(col) for col in self.columns}
def to_json(self) -> dict:
"""
Alias for :meth:`.to_dict`.
"""
return self.to_dict()
def __repr__(self):
"""
Same as :meth:`.__str__`.
"""
return str(self)
def __str__(self):
return json.dumps(self.to_json())
"""
:return: A JSON-encoded representation of the entity.
"""
return json.dumps(self.to_dict())
def __setattr__(self, key, value):
"""
Serializes the new value before assigning it to an attribute.
"""
matching_columns = [c for c in self.columns if c.expression.name == key]
if (
@ -153,6 +188,12 @@ if 'entity' not in Base.metadata:
# standard multiple inheritance with an SQLAlchemy ORM class)
Entity.__bases__ = Entity.__bases__ + (JSONAble,)
EntitySavedCallback = Callable[[Entity], None]
"""
Type for the callback functions that should be called when an entity is saved
on the database.
"""
def _discover_entity_types():
from platypush.context import get_plugin
@ -171,8 +212,15 @@ def _discover_entity_types():
module = types.ModuleType(mod_loader.name)
mod_loader.loader.exec_module(module)
except Exception as e:
logger.warning(f'Could not import module {modname}')
logger.exception(e)
if (
isinstance(e, (ImportError, ModuleNotFoundError))
and modname[len(__package__) + 1 :] in _import_error_ignored_modules
):
logger.debug(f'Could not import module {modname}')
else:
logger.warning(f'Could not import module {modname}')
logger.exception(e)
continue
for _, obj in inspect.getmembers(module):
@ -180,11 +228,17 @@ def _discover_entity_types():
entities_registry[obj] = {}
def get_entities_registry():
def get_entities_registry() -> EntityRegistryType:
"""
:returns: A copy of the entities registry.
"""
return entities_registry.copy()
def init_entities_db():
"""
Initializes the entities database.
"""
from platypush.context import get_plugin
_discover_entity_types()

View File

@ -1,12 +1,13 @@
from logging import getLogger
from threading import Thread, Event
from typing import Dict, Optional, Tuple
from platypush.context import get_bus
from platypush.entities import Entity
from platypush.message.event.entities import EntityUpdateEvent
from platypush.utils import set_thread_name
# pylint: disable=no-name-in-module
from platypush.entities._base import EntitySavedCallback
from platypush.entities._engine.queue import EntitiesQueue
from platypush.entities._engine.repo import EntitiesRepository
@ -29,18 +30,39 @@ class EntitiesEngine(Thread):
"""
def __init__(self):
def __init__(self) -> None:
obj_name = self.__class__.__name__
super().__init__(name=obj_name)
self.logger = getLogger(name=obj_name)
self._should_stop = Event()
""" Event used to synchronize stop events downstream."""
self._running = Event()
"""
Event used to synchronize other threads to wait for the engine to
start.
"""
self._queue = EntitiesQueue(stop_event=self._should_stop)
""" Queue where all entity upsert requests are received."""
self._repo = EntitiesRepository()
""" The repository of the processed entities. """
self._callbacks: Dict[Tuple[str, str], EntitySavedCallback] = {}
""" (external_id, plugin) -> callback mapping"""
def post(self, *entities: Entity, callback: Optional[EntitySavedCallback] = None):
if callback:
for entity in entities:
self._callbacks[entity.entity_key] = callback
def post(self, *entities: Entity):
self._queue.put(*entities)
def wait_start(self, timeout: Optional[float] = None) -> None:
started = self._running.wait(timeout=timeout)
if not started:
raise TimeoutError(
f'Timeout waiting for {self.__class__.__name__} to start.'
)
@property
def should_stop(self) -> bool:
return self._should_stop.is_set()
@ -52,31 +74,52 @@ class EntitiesEngine(Thread):
"""
Trigger an EntityUpdateEvent if the entity has been persisted, or queue
it to the list of entities whose notifications will be flushed when the
session is committed.
session is committed. It will also invoke any registered callbacks.
"""
for entity in entities:
get_bus().post(EntityUpdateEvent(entity=entity))
self._process_callback(entity)
def _process_callback(self, entity: Entity) -> None:
"""
Process the callback for the given entity.
"""
callback = self._callbacks.pop(entity.entity_key, None)
if callback:
try:
callback(entity)
except Exception as e:
self.logger.error(
'Error while notifying updates for entity ID %d via %s: %s',
entity.id,
callback,
e,
)
self.logger.exception(e)
def run(self):
super().run()
set_thread_name('entities')
self.logger.info('Started entities engine')
self._running.set()
while not self.should_stop:
# Get a batch of entity updates forwarded by other integrations
entities = self._queue.get()
if not entities or self.should_stop:
continue
try:
while not self.should_stop:
# Get a batch of entity updates forwarded by other integrations
entities = self._queue.get()
if not entities or self.should_stop:
continue
# Store the batch of entities
try:
entities = self._repo.save(*entities)
except Exception as e:
self.logger.error('Error while processing entity updates: %s', e)
self.logger.exception(e)
continue
# Store the batch of entities
try:
entities = self._repo.save(*entities)
except Exception as e:
self.logger.error('Error while processing entity updates: %s', e)
self.logger.exception(e)
continue
# Trigger EntityUpdateEvent events
self.notify(*entities)
self.logger.info('Stopped entities engine')
# Trigger EntityUpdateEvent events
self.notify(*entities)
finally:
self.logger.info('Stopped entities engine')
self._running.clear()

View File

@ -37,7 +37,12 @@ class EntitiesRepository:
the taxonomies.
"""
with self._db.get_session(locked=True, autoflush=False) as session:
with self._db.get_session(
locked=True,
autoflush=False,
autocommit=False,
expire_on_commit=False,
) as session:
merged_entities = self._merger.merge(session, entities)
merged_entities = self._db.upsert(session, merged_entities)

View File

@ -3,7 +3,6 @@ from dataclasses import dataclass
from typing import Dict, Iterable, List, Tuple
from sqlalchemy import and_, or_
from sqlalchemy.exc import InvalidRequestError
from sqlalchemy.orm import Session
from platypush.context import get_plugin
@ -179,18 +178,12 @@ class EntitiesDb:
session.commit()
all_entities = list(
# Make a copy of the entities in the batch, so they can be used outside
# of this session/thread without the DetachedInstanceError pain
return list(
{
entity.entity_key: entity for batch in batches for entity in batch
entity.entity_key: entity.copy()
for batch in batches
for entity in batch
}.values()
)
# Remove all the entities from the existing session, so they can be
# accessed outside of this context
for e in all_entities:
try:
session.expunge(e)
except InvalidRequestError:
pass
return all_entities

View File

@ -1,10 +1,11 @@
from typing import Dict, Iterable, List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, exc
from platypush.entities import Entity
# pylint: disable=too-few-public-methods
class EntitiesMerger:
"""
This object is in charge of detecting and merging entities that already
@ -103,7 +104,12 @@ class EntitiesMerger:
the parent.
"""
parent_id: Optional[int] = entity.parent_id
parent: Optional[Entity] = entity.parent
try:
parent: Optional[Entity] = entity.parent
except exc.DetachedInstanceError:
# Dirty fix for `Parent instance <...> is not bound to a Session;
# lazy load operation of attribute 'parent' cannot proceed
parent = session.query(Entity).get(parent_id) if parent_id else None
# If the entity has a parent with an ID, use that
if parent and parent.id:

View File

@ -5,7 +5,7 @@ from datetime import datetime
from typing import Any, Optional, Dict, Collection, Type
from platypush.config import Config
from platypush.entities import Entity
from platypush.entities._base import Entity, EntitySavedCallback
from platypush.utils import get_plugin_name_by_class, get_redis
_entity_registry_varname = '_platypush/plugin_entity_registry'
@ -68,7 +68,7 @@ class EntityManager(ABC):
def _normalize_entities(self, entities: Collection[Entity]) -> Collection[Entity]:
for entity in entities:
if entity.id:
if entity.id and not entity.external_id:
# Entity IDs can only refer to the internal primary key
entity.external_id = entity.id
entity.id = None # type: ignore
@ -80,7 +80,9 @@ class EntityManager(ABC):
return entities
def publish_entities(
self, entities: Optional[Collection[Any]]
self,
entities: Optional[Collection[Any]],
callback: Optional[EntitySavedCallback] = None,
) -> Collection[Entity]:
"""
Publishes a list of entities. The downstream consumers include:
@ -91,6 +93,9 @@ class EntityManager(ABC):
:class:`platypush.message.event.entities.EntityUpdateEvent`
events (e.g. web clients)
It also accepts an optional callback that will be called when each of
the entities in the set is flushed to the database.
You usually don't need to override this class (but you may want to
extend :meth:`.transform_entities` instead if your extension doesn't
natively handle `Entity` objects).
@ -101,7 +106,7 @@ class EntityManager(ABC):
self.transform_entities(entities or [])
)
publish_entities(transformed_entities)
publish_entities(transformed_entities, callback=callback)
return transformed_entities

View File

@ -1,79 +0,0 @@
from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Integer,
JSON,
String,
)
from platypush.common.db import Base
from .devices import Device
if 'bluetooth_device' not in Base.metadata:
class BluetoothDevice(Device):
"""
Entity that represents a Bluetooth device.
"""
__tablename__ = 'bluetooth_device'
id = Column(
Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True
)
connected = Column(Boolean, default=False)
""" Whether the device is connected. """
paired = Column(Boolean, default=False)
""" Whether the device is paired. """
trusted = Column(Boolean, default=False)
""" Whether the device is trusted. """
blocked = Column(Boolean, default=False)
""" Whether the device is blocked. """
rssi = Column(Integer, default=None)
""" Received Signal Strength Indicator. """
tx_power = Column(Integer, default=None)
""" Reported transmission power. """
manufacturers = Column(JSON)
""" Registered manufacturers for the device, as an ID -> Name map. """
uuids = Column(JSON)
"""
Service/characteristic UUIDs exposed by the device, as a
UUID -> Name map.
"""
brand = Column(String)
""" Device brand, as a string. """
model = Column(String)
""" Device model, as a string. """
model_id = Column(String)
""" Device model ID. """
manufacturer_data = Column(JSON)
"""
Latest manufacturer data published by the device, as a
``manufacturer_id -> data`` map, where ``data`` is a hexadecimal
string.
"""
service_data = Column(JSON)
"""
Latest service data published by the device, as a ``service_uuid ->
data`` map, where ``data`` is a hexadecimal string.
"""
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}

View File

@ -0,0 +1,7 @@
from ._device import BluetoothDevice
from ._service import BluetoothService
__all__ = [
"BluetoothDevice",
"BluetoothService",
]

View File

@ -0,0 +1,167 @@
from typing import Dict, Iterable, List
from typing_extensions import override
from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Integer,
JSON,
String,
)
from platypush.common.db import Base
from platypush.plugins.bluetooth.model import (
MajorDeviceClass,
MajorServiceClass,
MinorDeviceClass,
ServiceClass,
)
from ..devices import Device
from ._service import BluetoothService
if 'bluetooth_device' not in Base.metadata:
class BluetoothDevice(Device):
"""
Entity that represents a Bluetooth device.
"""
__tablename__ = 'bluetooth_device'
id = Column(
Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True
)
address = Column(String, nullable=False)
""" Device address. """
connected = Column(Boolean, default=False)
""" Whether the device is connected. """
supports_ble = Column(Boolean, default=False)
"""
Whether the device supports the Bluetooth Low-Energy specification.
"""
supports_legacy = Column(Boolean, default=False)
"""
Whether the device supports the legacy (non-BLE) specification.
"""
rssi = Column(Integer, default=None)
""" Received Signal Strength Indicator. """
tx_power = Column(Integer, default=None)
""" Reported transmission power. """
_major_service_classes = Column("major_service_classes", JSON, default=None)
""" The reported major service classes, as a list of strings. """
_major_device_class = Column("major_device_class", String, default=None)
""" The reported major device class. """
_minor_device_classes = Column("minor_device_classes", JSON, default=None)
""" The reported minor device classes, as a list of strings. """
manufacturer = Column(String, default=None)
""" Device manufacturer, as a string. """
model = Column(String, default=None)
""" Device model, as a string. """
model_id = Column(String, default=None)
""" Device model ID. """
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
@property
def major_device_class(self) -> MajorDeviceClass:
ret = MajorDeviceClass.UNKNOWN
if self._major_device_class:
matches = [
cls
for cls in MajorDeviceClass
if cls.value.name == self._major_device_class
]
if matches:
ret = matches[0]
return ret
@major_device_class.setter
def major_device_class(self, value: MajorDeviceClass):
self._major_device_class = value.value.name
@property
def minor_device_classes(self) -> List[MinorDeviceClass]:
ret = []
for dev_cls in self._minor_device_classes or []:
matches = [cls for cls in MinorDeviceClass if cls.value.name == dev_cls]
if matches:
ret.append(matches[0])
return ret
@minor_device_classes.setter
def minor_device_classes(self, value: Iterable[MinorDeviceClass]):
self._minor_device_classes = [cls.value.name for cls in (value or [])]
@property
def major_service_classes(self) -> List[MajorServiceClass]:
ret = []
for dev_cls in self._major_service_classes or []:
matches = [
cls for cls in MajorServiceClass if cls.value.name == dev_cls
]
if matches:
ret.append(matches[0])
return ret
@major_service_classes.setter
def major_service_classes(self, value: Iterable[MajorServiceClass]):
self._major_service_classes = [cls.value.name for cls in (value or [])]
@property
def services(self) -> List[BluetoothService]:
"""
:return: List of
:class:`platypush.plugins.bluetooth.model.BluetoothService` mapping
all the services exposed by the device.
"""
return [
child for child in self.children if isinstance(child, BluetoothService)
]
@property
def known_services(self) -> Dict[ServiceClass, "BluetoothService"]:
"""
Known services exposed by the device, indexed by
:class:`platypush.plugins.bluetooth.model.ServiceClass` enum value.
"""
return {
child.service_class: child
for child in self.children
if isinstance(child, BluetoothService)
and child.service_class != ServiceClass.UNKNOWN
}
@override
def to_dict(self):
"""
Overwrites ``to_dict`` to transform private column names into their
public representation, and also include the exposed services and
child entities.
"""
return {
**{k.lstrip('_'): v for k, v in super().to_dict().items()},
'children': [child.to_dict() for child in self.children],
}

View File

@ -0,0 +1,133 @@
from typing import Union
from typing_extensions import override
from uuid import UUID
from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Integer,
String,
)
from platypush.common.db import Base
from platypush.entities import Entity
from platypush.plugins.bluetooth.model import (
Protocol,
RawServiceClass,
ServiceClass,
)
if 'bluetooth_service' not in Base.metadata:
class BluetoothService(Entity):
"""
Entity that represents a Bluetooth service.
"""
__tablename__ = 'bluetooth_service'
id = Column(
Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True
)
_uuid = Column('uuid', String, nullable=False)
"""
The service class UUID. It can be either a 16-bit or a 128-bit UUID.
"""
_protocol = Column('protocol', String, default=None)
""" The protocol used by the service. """
port = Column(Integer, default=None)
""" The port used by the service. """
version = Column(Integer, default=None)
""" The version of the service profile. """
is_ble = Column(Boolean, default=False)
""" Whether the service is a BLE service. """
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
@staticmethod
def to_uuid(value: Union[str, RawServiceClass]) -> RawServiceClass:
"""
Convert a raw UUID string to a service class UUID.
"""
# If it's already a UUID or an int, just return it.
if isinstance(value, (UUID, int)):
return value
try:
# If it's formatted like a 128-bit UUID, convert it to a UUID
# object.
return UUID(value)
except ValueError:
# Hex string case
return int(value, 16)
@property
def uuid(self) -> RawServiceClass:
"""
Getter for the service class UUID.
"""
return self.to_uuid(self._uuid)
@uuid.setter
def uuid(self, value: Union[RawServiceClass, str]):
"""
Setter for the service class UUID.
"""
uuid: Union[RawServiceClass, str] = self.to_uuid(value)
if isinstance(uuid, int):
# Hex-encoded 16-bit UUID case
uuid = f'{uuid:04X}'
self._uuid = str(uuid)
@property
def protocol(self) -> Protocol:
"""
Getter for the protocol used by the service.
"""
try:
return Protocol(self._protocol)
except ValueError:
return Protocol.UNKNOWN
@protocol.setter
def protocol(self, value: Union[str, Protocol]):
"""
Setter for the protocol used by the service.
"""
protocol = Protocol.UNKNOWN
if isinstance(value, Protocol):
protocol = value
else:
try:
protocol = Protocol(value)
except ValueError:
pass
self._protocol = protocol.value
@property
def service_class(self) -> ServiceClass:
"""
The :class:`platypush.plugins.bluetooth.model.ServiceClass` enum
value.
"""
try:
return ServiceClass.get(self.uuid)
except (TypeError, ValueError):
return ServiceClass.UNKNOWN
@override
def to_dict(self) -> dict:
return {
**{k.lstrip('_'): v for k, v in super().to_dict().items()},
# Human-readable service class name
'service_class': str(self.service_class),
}

View File

@ -53,6 +53,7 @@ if 'raw_sensor' not in Base.metadata:
If ``is_json`` is ``True``, then ``value`` is a JSON-encoded string
object or array.
"""
unit = Column(String)
@property
def value(self):

View File

@ -7,6 +7,7 @@ import inspect
import json
import time
from typing import Union
from uuid import UUID
logger = logging.getLogger('platypush')
@ -60,6 +61,9 @@ class Message:
if isinstance(obj, set):
return list(obj)
if isinstance(obj, UUID):
return str(obj)
value = self.parse_numpy(obj)
if value is not None:
return value

View File

@ -1,5 +1,6 @@
from typing import Dict, Optional
from typing import Iterable, Optional
from platypush.entities.bluetooth import BluetoothDevice
from platypush.message.event import Event
@ -27,19 +28,7 @@ class BluetoothScanResumedEvent(BluetoothEvent):
super().__init__(*args, duration=duration, **kwargs)
class BluetoothWithPortEvent(Event):
"""
Base class for Bluetooth events with an associated port.
"""
def __init__(self, *args, port: Optional[str] = None, **kwargs):
"""
:param port: The communication port of the device.
"""
super().__init__(*args, port=port, **kwargs)
class BluetoothDeviceEvent(BluetoothWithPortEvent):
class BluetoothDeviceEvent(BluetoothEvent):
"""
Base class for Bluetooth device events.
"""
@ -49,59 +38,52 @@ class BluetoothDeviceEvent(BluetoothWithPortEvent):
*args,
address: str,
connected: bool,
paired: bool,
trusted: bool,
blocked: bool,
name: Optional[str] = None,
uuids: Optional[Dict[str, str]] = None,
rssi: Optional[int] = None,
tx_power: Optional[int] = None,
manufacturers: Optional[Dict[int, str]] = None,
manufacturer_data: Optional[Dict[int, str]] = None,
service_data: Optional[Dict[str, str]] = None,
manufacturer: Optional[str] = None,
services: Optional[Iterable[dict]] = None,
**kwargs
):
"""
:param address: The Bluetooth address of the device.
:param connected: Whether the device is connected.
:param paired: Whether the device is paired.
:param trusted: Whether the device is trusted.
:param blocked: Whether the device is blocked.
:param name: The name of the device.
:param uuids: The UUIDs of the services exposed by the device.
:param rssi: Received Signal Strength Indicator.
:param tx_power: Transmission power.
:param manufacturers: The manufacturers published by the device, as a
``manufacturer_id -> registered_name`` map.
:param manufacturer_data: The manufacturer data published by the
device, as a ``manufacturer_id -> data`` map, where ``data`` is a
hexadecimal string.
:param service_data: The service data published by the device, as a
``service_uuid -> data`` map, where ``data`` is a hexadecimal string.
:param services: The services published by the device.
"""
super().__init__(
*args,
address=address,
name=name,
connected=connected,
paired=paired,
blocked=blocked,
trusted=trusted,
uuids=uuids or {},
rssi=rssi,
tx_power=tx_power,
manufacturers=manufacturers or {},
manufacturer_data=manufacturer_data or {},
service_data=service_data or {},
manufacturer=manufacturer,
services=services,
**kwargs
)
@classmethod
def from_device(cls, device: BluetoothDevice, **kwargs) -> "BluetoothDeviceEvent":
"""
Initialize a Bluetooth event from the parameters of a device.
class BluetoothDeviceNewDataEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device publishes new manufacturer/service
data.
"""
:param device: Bluetooth device.
"""
return cls(
address=device.address,
name=device.name,
connected=device.connected,
rssi=device.rssi,
tx_power=device.tx_power,
manufacturer=device.manufacturer,
services=[srv.to_dict() for srv in device.services],
**kwargs
)
class BluetoothDeviceFoundEvent(BluetoothDeviceEvent):
@ -128,73 +110,61 @@ class BluetoothDeviceDisconnectedEvent(BluetoothDeviceEvent):
"""
class BluetoothDevicePairedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is paired.
"""
class BluetoothDeviceUnpairedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is unpaired.
"""
class BluetoothDeviceBlockedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is blocked.
"""
class BluetoothDeviceUnblockedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is unblocked.
"""
class BluetoothDeviceTrustedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is trusted.
"""
class BluetoothDeviceSignalUpdateEvent(BluetoothDeviceEvent):
"""
Event triggered when the RSSI/TX power of a Bluetooth device is updated.
"""
class BluetoothDeviceUntrustedEvent(BluetoothDeviceEvent):
class BluetoothConnectionFailedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is untrusted.
Event triggered when a Bluetooth connection fails.
"""
class BluetoothConnectionRejectedEvent(BluetoothDeviceEvent):
class BluetoothFileEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth connection is rejected.
Base class for Bluetooth file events.
"""
def __init__(self, *args, file: str, **kwargs):
super().__init__(*args, file=file, **kwargs)
class BluetoothFileTransferStartedEvent(BluetoothFileEvent):
"""
Event triggered when a file transfer is initiated.
"""
class BluetoothFilePutRequestEvent(BluetoothWithPortEvent):
class BluetoothFileTransferCancelledEvent(BluetoothFileEvent):
"""
Event triggered when a file transfer is cancelled.
"""
class BluetoothFileReceivedEvent(BluetoothFileEvent):
"""
Event triggered when a file download is completed.
"""
class BluetoothFileSentEvent(BluetoothFileEvent):
"""
Event triggered when a file upload is completed.
"""
class BluetoothFilePutRequestEvent(BluetoothFileEvent):
"""
Event triggered when a file put request is received.
"""
class BluetoothFileGetRequestEvent(BluetoothWithPortEvent):
class BluetoothFileGetRequestEvent(BluetoothFileEvent):
"""
Event triggered when a file get request is received.
"""
class BluetoothFileReceivedEvent(BluetoothEvent):
"""
Event triggered when a file transfer is completed.
"""
def __init__(self, *args, path: str, **kwargs):
super().__init__(*args, path=path, **kwargs)
# vim:sw=4:ts=4:et:

View File

@ -7,7 +7,7 @@ from platypush.message.event import Event
class EntityEvent(Event):
def __init__(self, entity: Union[Entity, dict], *args, **kwargs):
if isinstance(entity, Entity):
entity = entity.to_json()
entity = entity.to_dict()
super().__init__(entity=entity, *args, **kwargs)

View File

@ -78,7 +78,7 @@ class Response(Message):
the message into a UTF-8 JSON string
"""
output = (
self.output if self.output is not None else {'success': bool(self.errors)}
self.output if self.output is not None else {'success': not self.errors}
)
response_dict = {

View File

@ -1,202 +0,0 @@
from platypush.message.response import Response
class BluetoothResponse(Response):
pass
class BluetoothScanResponse(BluetoothResponse):
def __init__(self, devices, *args, **kwargs):
if isinstance(devices, list):
self.devices = [
{
'addr': dev[0],
'name': dev[1] if len(dev) > 1 else None,
'class': hex(dev[2]) if len(dev) > 2 else None,
}
for dev in devices
]
elif isinstance(devices, dict):
self.devices = [
{
'addr': addr,
'name': name or None,
'class': 'BLE',
}
for addr, name in devices.items()
]
else:
raise ValueError('devices must be either a list of tuples or a dict')
super().__init__(output=self.devices, *args, **kwargs)
class BluetoothLookupNameResponse(BluetoothResponse):
def __init__(self, addr: str, name: str, *args, **kwargs):
self.addr = addr
self.name = name
super().__init__(output={
'addr': self.addr,
'name': self.name
}, *args, **kwargs)
class BluetoothLookupServiceResponse(BluetoothResponse):
"""
Example services response output:
.. code-block:: json
[
{
"service-classes": [
"1801"
],
"profiles": [],
"name": "Service name #1",
"description": null,
"provider": null,
"service-id": null,
"protocol": "L2CAP",
"port": 31,
"host": "00:11:22:33:44:55"
},
{
"service-classes": [
"1800"
],
"profiles": [],
"name": "Service name #2",
"description": null,
"provider": null,
"service-id": null,
"protocol": "L2CAP",
"port": 31,
"host": "00:11:22:33:44:56"
},
{
"service-classes": [
"1112",
"1203"
],
"profiles": [
[
"1108",
258
]
],
"name": "Headset Gateway",
"description": null,
"provider": null,
"service-id": null,
"protocol": "RFCOMM",
"port": 2,
"host": "00:11:22:33:44:57"
}
]
"""
def __init__(self, services: list, *args, **kwargs):
self.services = services
super().__init__(output=self.services, *args, **kwargs)
class BluetoothDiscoverPrimaryResponse(BluetoothResponse):
"""
Example services response output:
.. code-block:: json
[
{
"uuid": "00001800-0000-1000-8000-00805f9b34fb",
"start": 1,
"end": 7
},
{
"uuid": "00001801-0000-1000-8000-00805f9b34fb",
"start": 8,
"end": 8
},
{
"uuid": "0000fee7-0000-1000-8000-00805f9b34fb",
"start": 9,
"end": 16
},
{
"uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b",
"start": 17,
"end": 65535
}
]
"""
def __init__(self, services: list, *args, **kwargs):
self.services = services
super().__init__(output=self.services, *args, **kwargs)
class BluetoothDiscoverCharacteristicsResponse(BluetoothResponse):
"""
Example services response output:
.. code-block:: json
[
{
"uuid": "00002a00-0000-1000-8000-00805f9b34fb",
"handle": 2,
"properties": 10,
"value_handle": 3
},
{
"uuid": "00002a01-0000-1000-8000-00805f9b34fb",
"handle": 4,
"properties": 2,
"value_handle": 5
},
{
"uuid": "00002a04-0000-1000-8000-00805f9b34fb",
"handle": 6,
"properties": 2,
"value_handle": 7
},
{
"uuid": "0000fec8-0000-1000-8000-00805f9b34fb",
"handle": 10,
"properties": 32,
"value_handle": 11
},
{
"uuid": "0000fec7-0000-1000-8000-00805f9b34fb",
"handle": 13,
"properties": 8,
"value_handle": 14
},
{
"uuid": "0000fec9-0000-1000-8000-00805f9b34fb",
"handle": 15,
"properties": 2,
"value_handle": 16
},
{
"uuid": "cba20003-224d-11e6-9fb8-0002a5d5c51b",
"handle": 18,
"properties": 16,
"value_handle": 19
},
{
"uuid": "cba20002-224d-11e6-9fb8-0002a5d5c51b",
"handle": 21,
"properties": 12,
"value_handle": 22
}
]
"""
def __init__(self, characteristics: list, *args, **kwargs):
self.characteristics = characteristics
super().__init__(output=self.characteristics, *args, **kwargs)
# vim:sw=4:ts=4:et:

View File

@ -80,13 +80,13 @@ class RunnablePlugin(Plugin):
def __init__(
self,
poll_interval: Optional[float] = 30,
poll_interval: Optional[float] = 15,
stop_timeout: Optional[float] = PLUGIN_STOP_TIMEOUT,
**kwargs,
):
"""
:param poll_interval: How often the :meth:`.loop` function should be
execute (default: 30 seconds).
execute (default: 15 seconds).
:param stop_timeout: How long we should wait for any running
threads/processes to stop before exiting (default: 5 seconds).
"""
@ -126,7 +126,11 @@ class RunnablePlugin(Plugin):
Stop the plugin.
"""
self._should_stop.set()
if self._thread and self._thread.is_alive():
if (
self._thread
and self._thread != threading.current_thread()
and self._thread.is_alive()
):
self.logger.info('Waiting for the plugin to stop')
try:
if self._thread:

View File

@ -1,434 +1,3 @@
import base64
import os
import re
import select
from ._plugin import BluetoothPlugin
from typing import Dict, Optional
from platypush.plugins.sensor import SensorPlugin
from platypush.plugins import action
from platypush.message.response.bluetooth import BluetoothScanResponse, \
BluetoothLookupNameResponse, BluetoothLookupServiceResponse, BluetoothResponse
class BluetoothPlugin(SensorPlugin):
"""
Bluetooth plugin
Requires:
* **pybluez** (``pip install pybluez``)
* **pyobex** (``pip install git+https://github.com/BlackLight/PyOBEX``)
"""
import bluetooth
class _DeviceDiscoverer(bluetooth.DeviceDiscoverer):
def __init__(self, name, *args, **kwargs):
# noinspection PyArgumentList
super().__init__(*args, **kwargs)
self.name = name
self.device = {}
self.done = True
def pre_inquiry(self):
self.done = False
# noinspection PyUnusedLocal
def device_discovered(self, dev_addr, dev_class, rssi, dev_name):
dev_name = dev_name.decode()
if dev_name == self.name:
self.device = {
'addr': dev_addr,
'name': dev_name,
'class': dev_class,
}
self.done = True
def inquiry_complete(self):
self.done = True
def __init__(self, device_id: int = -1, **kwargs):
"""
:param device_id: Default adapter device_id to be used (default: -1, auto)
"""
super().__init__(**kwargs)
self.device_id = device_id
self._devices = []
self._devices_by_addr = {}
self._devices_by_name = {}
self._port_and_protocol_by_addr_and_srv_uuid = {}
self._port_and_protocol_by_addr_and_srv_name = {}
self._socks = {}
def _get_device_addr(self, device):
if re.match('([0-9A-F]{2}:){5}[0-9A-F]{2}', device, re.IGNORECASE):
return device
if device in self._devices_by_name:
return self._devices_by_name[device]['addr']
return self.lookup_address(device).output['addr']
@action
def scan(self, device_id: Optional[int] = None, duration: int = 10) -> BluetoothScanResponse:
"""
Scan for nearby bluetooth devices
:param device_id: Bluetooth adapter ID to use (default configured if None)
:param duration: Scan duration in seconds
"""
from bluetooth import discover_devices
if device_id is None:
device_id = self.device_id
self.logger.debug('Discovering devices on adapter {}, duration: {} seconds'.format(
device_id, duration))
devices = discover_devices(duration=duration, lookup_names=True, lookup_class=True, device_id=device_id,
flush_cache=True)
response = BluetoothScanResponse(devices)
self._devices = response.devices
self._devices_by_addr = {dev['addr']: dev for dev in self._devices}
self._devices_by_name = {dev['name']: dev for dev in self._devices if dev.get('name')}
return response
@action
def get_measurement(self, device_id: Optional[int] = None, duration: Optional[int] = 10, *args, **kwargs) \
-> Dict[str, dict]:
"""
Wrapper for ``scan`` that returns bluetooth devices in a format usable by sensor backends.
:param device_id: Bluetooth adapter ID to use (default configured if None)
:param duration: Scan duration in seconds
:return: Device address -> info map.
"""
devices = self.scan(device_id=device_id, duration=duration).output
return {device['addr']: device for device in devices}
@action
def lookup_name(self, addr: str, timeout: int = 10) -> BluetoothLookupNameResponse:
"""
Look up the name of a nearby bluetooth device given the address
:param addr: Device address
:param timeout: Lookup timeout (default: 10 seconds)
"""
from bluetooth import lookup_name
self.logger.debug('Looking up name for device {}'.format(addr))
name = lookup_name(addr, timeout=timeout)
dev = {
'addr': addr,
'name': name,
'class': self._devices_by_addr.get(addr, {}).get('class'),
}
self._devices_by_addr[addr] = dev
if name:
self._devices_by_name[name] = dev
return BluetoothLookupNameResponse(addr=addr, name=name)
@action
def lookup_address(self, name: str, timeout: int = 10) -> BluetoothLookupNameResponse:
"""
Look up the address of a nearby bluetooth device given the name
:param name: Device name
:param timeout: Lookup timeout (default: 10 seconds)
"""
self.logger.info('Looking up address for device {}'.format(name))
discoverer = self._DeviceDiscoverer(name)
discoverer.find_devices(lookup_names=True, duration=timeout)
readfiles = [discoverer]
while True:
rfds = select.select(readfiles, [], [])[0]
if discoverer in rfds:
discoverer.process_event()
if discoverer.done:
break
dev = discoverer.device
if not dev:
raise RuntimeError('No such device: {}'.format(name))
addr = dev.get('addr')
self._devices_by_addr[addr] = dev
self._devices_by_name[name] = dev
return BluetoothLookupNameResponse(addr=addr, name=name)
@action
def find_service(self, name: str = None, addr: str = None, uuid: str = None) -> BluetoothLookupServiceResponse:
"""
Look up for a service published by a nearby bluetooth device. If all the parameters are null then all the
published services on the nearby devices will be returned. See
`:class:platypush.message.response.bluetoothBluetoothLookupServiceResponse` for response structure reference.
:param name: Service name
:param addr: Service/device address
:param uuid: Service UUID
"""
import bluetooth
from bluetooth import find_service
services = find_service(name=name, address=addr, uuid=uuid)
self._port_and_protocol_by_addr_and_srv_uuid.update({
(srv['host'], srv['service-id']): (srv['port'], getattr(bluetooth, srv['protocol']))
for srv in services if srv.get('service-id')
})
self._port_and_protocol_by_addr_and_srv_name.update({
(srv['host'], srv['name']): (srv['port'], getattr(bluetooth, srv['protocol']))
for srv in services if srv.get('name')
})
return BluetoothLookupServiceResponse(services)
def _get_sock(self, protocol=None, device: str = None, port: int = None, service_uuid: str = None,
service_name: str = None, connect_if_closed=False):
sock = None
addr = self._get_device_addr(device)
if not (addr and port and protocol):
addr, port, protocol = self._get_addr_port_protocol(protocol=protocol, device=device, port=port,
service_uuid=service_uuid, service_name=service_name)
if (addr, port) in self._socks:
sock = self._socks[(addr, port)]
elif connect_if_closed:
self.connect(protocol=protocol, device=device, port=port, service_uuid=service_uuid,
service_name=service_name)
sock = self._socks[(addr, port)]
return sock
def _get_addr_port_protocol(self, protocol=None, device: str = None, port: int = None, service_uuid: str = None,
service_name: str = None) -> tuple:
import bluetooth
addr = self._get_device_addr(device) if device else None
if service_uuid or service_name:
if addr:
if service_uuid:
(port, protocol) = self._port_and_protocol_by_addr_and_srv_uuid[(addr, service_uuid)] \
if (addr, service_uuid) in self._port_and_protocol_by_addr_and_srv_uuid else \
(None, None)
else:
(port, protocol) = self._port_and_protocol_by_addr_and_srv_name[(addr, service_name)] \
if (addr, service_name) in self._port_and_protocol_by_addr_and_srv_name else \
(None, None)
if not (addr and port):
self.logger.info('Discovering devices, service_name={name}, uuid={uuid}, address={addr}'.format(
name=service_name, uuid=service_uuid, addr=addr))
services = [
srv for srv in self.find_service().services
if (service_name is None or srv.get('name') == service_name) and
(addr is None or srv.get('host') == addr) and
(service_uuid is None or srv.get('service-id') == service_uuid)
]
if not services:
raise RuntimeError('No such service: name={name} uuid={uuid} address={addr}'.format(
name=service_name, uuid=service_uuid, addr=addr))
service = services[0]
addr = service['host']
port = service['port']
protocol = getattr(bluetooth, service['protocol'])
elif protocol:
if isinstance(protocol, str):
protocol = getattr(bluetooth, protocol)
else:
raise RuntimeError('No service name/UUID nor bluetooth protocol (RFCOMM/L2CAP) specified')
if not (addr and port):
raise RuntimeError('No valid device name/address, port, service name or UUID specified')
return addr, port, protocol
@action
def connect(self, protocol=None, device: str = None, port: int = None, service_uuid: str = None,
service_name: str = None):
"""
Connect to a bluetooth device.
You can query the advertised services through ``find_service``.
:param protocol: Supported values: either 'RFCOMM'/'L2CAP' (str) or bluetooth.RFCOMM/bluetooth.L2CAP
int constants (int)
:param device: Device address or name
:param port: Port number
:param service_uuid: Service UUID
:param service_name: Service name
"""
from bluetooth import BluetoothSocket
addr, port, protocol = self._get_addr_port_protocol(protocol=protocol, device=device, port=port,
service_uuid=service_uuid, service_name=service_name)
sock = self._get_sock(protocol=protocol, device=addr, port=port)
if sock:
self.close(device=addr, port=port)
sock = BluetoothSocket(protocol)
self.logger.info('Opening connection to device {} on port {}'.format(addr, port))
sock.connect((addr, port))
self.logger.info('Connected to device {} on port {}'.format(addr, port))
self._socks[(addr, port)] = sock
@action
def close(self, device: str = None, port: int = None, service_uuid: str = None, service_name: str = None):
"""
Close an active bluetooth connection
:param device: Device address or name
:param port: Port number
:param service_uuid: Service UUID
:param service_name: Service name
"""
sock = self._get_sock(device=device, port=port, service_uuid=service_uuid, service_name=service_name)
if not sock:
self.logger.debug('Close on device {}({}) that is not connected'.format(device, port))
return
try:
sock.close()
except Exception as e:
self.logger.warning('Exception while closing previous connection to {}({}): {}'.format(
device, port, str(e)))
@action
def send(self, data, device: str = None, port: int = None, service_uuid: str = None, service_name: str = None,
binary: bool = False):
"""
Send data to an active bluetooth connection
:param data: Data to be sent
:param device: Device address or name
:param service_uuid: Service UUID
:param service_name: Service name
:param port: Port number
:param binary: Set to true if msg is a base64-encoded binary string
"""
from bluetooth import BluetoothError
sock = self._get_sock(device=device, port=port, service_uuid=service_uuid, service_name=service_name,
connect_if_closed=True)
if binary:
data = base64.decodebytes(data.encode() if isinstance(data, str) else data)
try:
sock.send(data)
except BluetoothError as e:
self.close(device=device, port=port, service_uuid=service_uuid, service_name=service_name)
raise e
@action
def recv(self, device: str, port: int, service_uuid: str = None, service_name: str = None, size: int = 1024,
binary: bool = False) -> BluetoothResponse:
"""
Send data to an active bluetooth connection
:param device: Device address or name
:param port: Port number
:param service_uuid: Service UUID
:param service_name: Service name
:param size: Maximum number of bytes to be read
:param binary: Set to true to return a base64-encoded binary string
"""
from bluetooth import BluetoothError
sock = self._get_sock(device=device, port=port, service_uuid=service_uuid, service_name=service_name,
connect_if_closed=True)
if not sock:
self.connect(device=device, port=port, service_uuid=service_uuid, service_name=service_name)
sock = self._get_sock(device=device, port=port, service_uuid=service_uuid, service_name=service_name)
try:
data = sock.recv(size)
except BluetoothError as e:
self.close(device=device, port=port, service_uuid=service_uuid, service_name=service_name)
raise e
if binary:
data = base64.encodebytes(data)
return BluetoothResponse(output=data.decode())
@action
def set_l2cap_mtu(self, mtu: int, device: str = None, port: int = None, service_name: str = None,
service_uuid: str = None):
"""
Set the L2CAP MTU (Maximum Transmission Unit) value for a connected bluetooth device.
Both the devices usually use the same MTU value over a connection.
:param device: Device address or name
:param port: Port number
:param service_uuid: Service UUID
:param service_name: Service name
:param mtu: New MTU value
"""
from bluetooth import BluetoothError, set_l2cap_mtu, L2CAP
sock = self._get_sock(protocol=L2CAP, device=device, port=port, service_uuid=service_uuid,
service_name=service_name, connect_if_closed=True)
if not sock:
raise RuntimeError('set_l2cap_mtu: device not connected')
try:
set_l2cap_mtu(sock, mtu)
except BluetoothError as e:
self.close(device=device, port=port, service_name=service_name, service_uuid=service_uuid)
raise e
@action
def send_file(self, filename: str, device: str, port: int = None, data=None, service_name='OBEX Object Push',
binary: bool = False):
"""
Send a local file to a device that exposes an OBEX Object Push service
:param filename: Path of the file to be sent
:param data: Alternatively to a file on disk you can send raw (string or binary) content
:param device: Device address or name
:param port: Port number
:param service_name: Service name
:param binary: Set to true if data is a base64-encoded binary string
"""
from PyOBEX.client import Client
if not data:
filename = os.path.abspath(os.path.expanduser(filename))
with open(filename, 'r') as f:
data = f.read()
filename = os.path.basename(filename)
else:
if binary:
data = base64.decodebytes(data.encode() if isinstance(data, str) else data)
addr, port, protocol = self._get_addr_port_protocol(device=device, port=port,
service_name=service_name)
client = Client(addr, port)
self.logger.info('Connecting to device {}'.format(addr))
client.connect()
self.logger.info('Sending file {} to device {}'.format(filename, addr))
client.put(filename, data)
self.logger.info('File {} sent to device {}'.format(filename, addr))
client.disconnect()
# vim:sw=4:ts=4:et:
__all__ = ["BluetoothPlugin"]

View File

@ -0,0 +1,5 @@
from ._manager import BLEManager
__all__ = ["BLEManager"]
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,41 @@
from typing import Dict, Iterable, Optional, Tuple
from typing_extensions import override
from bleak.backends.device import BLEDevice
from .._cache import BaseCache
class DeviceCache(BaseCache):
"""
Cache used to store scanned Bluetooth devices as :class:`BLEDevice`.
"""
_by_address: Dict[str, BLEDevice]
_by_name: Dict[str, BLEDevice]
@property
@override
def _address_field(self) -> str:
return 'address'
@property
@override
def _name_field(self) -> str:
return 'name'
@override
def get(self, device: str) -> Optional[BLEDevice]:
return super().get(device)
@override
def add(self, device: BLEDevice) -> BLEDevice:
return super().add(device)
@override
def values(self) -> Iterable[BLEDevice]:
return super().values()
@override
def items(self) -> Iterable[Tuple[str, BLEDevice]]:
return super().items()

View File

@ -0,0 +1,20 @@
import asyncio
from dataclasses import dataclass
import threading
from typing import Optional
from bleak import BleakClient
from bleak.backends.device import BLEDevice
@dataclass
class BluetoothConnection:
"""
A class to store information and context about a Bluetooth connection.
"""
client: BleakClient
device: BLEDevice
loop: asyncio.AbstractEventLoop
close_event: Optional[asyncio.Event] = None
thread: Optional[threading.Thread] = None

View File

@ -0,0 +1,135 @@
from datetime import datetime, timedelta
from queue import Queue
from typing import Callable, Dict, Final, List, Optional, Type
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from platypush.entities.bluetooth import BluetoothDevice
from platypush.message.event.bluetooth import (
BluetoothDeviceConnectedEvent,
BluetoothDeviceDisconnectedEvent,
BluetoothDeviceFoundEvent,
BluetoothDeviceSignalUpdateEvent,
BluetoothDeviceEvent,
)
from platypush.context import get_bus
from .._cache import EntityCache
from ._cache import DeviceCache
from ._mappers import device_to_entity
_rssi_update_interval: Final[float] = 30.0
""" How often to trigger RSSI update events for a device. """
def _has_changed(
old: Optional[BluetoothDevice], new: BluetoothDevice, attr: str
) -> bool:
"""
Returns True if the given attribute has changed on the device.
"""
if old is None:
return False # No previous value
old_value = getattr(old, attr)
new_value = getattr(new, attr)
return old_value != new_value
def _has_been_set(
old: Optional[BluetoothDevice], new: BluetoothDevice, attr: str, value: bool
) -> bool:
"""
Returns True if the given attribute has changed and its new value matches
the given value.
"""
if not _has_changed(old, new, attr):
return False
new_value = getattr(new, attr)
return new_value == value
event_matchers: Dict[
Type[BluetoothDeviceEvent],
Callable[[Optional[BluetoothDevice], BluetoothDevice], bool],
] = {
BluetoothDeviceConnectedEvent: lambda old, new: _has_been_set(
old, new, 'connected', True
),
BluetoothDeviceDisconnectedEvent: lambda old, new: old is not None
and old.connected
and _has_been_set(old, new, 'connected', False),
BluetoothDeviceFoundEvent: lambda old, new: old is None
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 (_has_changed(old, new, 'rssi') or _has_changed(old, new, 'tx_power'))
and (
not (old and old.updated_at)
or datetime.utcnow() - old.updated_at
>= timedelta(seconds=_rssi_update_interval)
)
),
}
""" A static ``BluetoothDeviceEvent -> MatchCallback`` mapping. """
# pylint: disable=too-few-public-methods
class EventHandler:
"""
Event handler for BLE devices.
"""
def __init__(
self,
device_queue: Queue,
device_cache: DeviceCache,
entity_cache: EntityCache,
):
"""
:param device_queue: Queue used to publish updated devices upstream.
:param device_cache: Device cache.
:param entity_cache: Entity cache.
"""
self._device_queue = device_queue
self._device_cache = device_cache
self._entity_cache = entity_cache
def __call__(self, device: BLEDevice, data: AdvertisementData):
"""
Handler for Bluetooth device advertisement packets.
1. It generates the relevant
:class:`platypush.message.event.bluetooth.BluetoothDeviceEvent` if the
state of the device has changed.
2. It builds the relevant
:class:`platypush.entity.bluetooth.BluetoothDevice` entity object
populated with children entities that contain the supported
properties.
3. Publishes the updated entity to the upstream queue.
:param device: The Bluetooth device.
:param data: The advertised data.
"""
events: List[BluetoothDeviceEvent] = []
existing_entity = self._entity_cache.get(device.address)
new_entity = device_to_entity(device, data)
events += [
event_type.from_device(new_entity)
for event_type, matcher in event_matchers.items()
if matcher(existing_entity, new_entity)
]
self._device_cache.add(device)
for event in events:
get_bus().post(event)
if events:
self._device_queue.put_nowait(new_entity)

View File

@ -0,0 +1,375 @@
import asyncio
from contextlib import asynccontextmanager
import threading
from typing import (
AsyncGenerator,
Collection,
Final,
List,
Optional,
Dict,
Union,
)
from bleak import BleakClient, BleakScanner
from bleak.backends.device import BLEDevice
from typing_extensions import override
from platypush.context import get_or_create_event_loop
from platypush.entities.bluetooth import BluetoothDevice
from platypush.message.event.bluetooth import (
BluetoothConnectionFailedEvent,
BluetoothDeviceDisconnectedEvent,
BluetoothDeviceLostEvent,
)
from ._cache import DeviceCache
from ._connection import BluetoothConnection
from ._event_handler import EventHandler
from .._manager import BaseBluetoothManager
from .._types import RawServiceClass
class BLEManager(BaseBluetoothManager):
"""
Integration for Bluetooth Low Energy (BLE) devices.
"""
_rssi_update_interval: Final[int] = 30
"""
How long we should wait before triggering an update event upon a new
RSSI update, in seconds.
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._connections: Dict[str, BluetoothConnection] = {}
"""
``address -> BluetoothConnection`` mapping containing the active
connections
"""
self._connection_locks: Dict[str, asyncio.Lock] = {}
"""
``address -> Lock`` locks used to synchronize concurrent access to
the devices
"""
self._device_cache = DeviceCache()
""" Cache of discovered ``BLEDevice`` objects. """
self._event_handler = EventHandler(
device_queue=self._device_queue,
device_cache=self._device_cache,
entity_cache=self._cache,
)
""" Bluetooth device event handler """
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
""" Main event loop """
async def _get_device(self, device: str) -> BLEDevice:
"""
Utility method to get a device by name or address.
"""
dev = self._device_cache.get(device)
if not dev:
self.logger.info('Scanning for unknown device "%s"', device)
await self._scan()
dev = self._device_cache.get(device)
assert dev, f'Unknown device: "{device}"'
return dev
def _disconnected_callback(self, client: BleakClient):
self._connections.pop(client.address, None)
dev = self._cache.get(client.address)
if not dev:
return # Unknown device
dev.connected = False
self.notify(BluetoothDeviceDisconnectedEvent, dev)
@asynccontextmanager
async def _connect(
self,
device: str,
interface: Optional[str] = None,
timeout: Optional[float] = None,
close_event: Optional[asyncio.Event] = None,
) -> AsyncGenerator[BluetoothConnection, None]:
"""
Asynchronous context manager that wraps a BLE device connection.
"""
dev = await self._get_device(device)
async with self._connection_locks.get(dev.address, asyncio.Lock()) as lock:
self._connection_locks[dev.address] = lock or asyncio.Lock()
async with BleakClient(
dev.address,
adapter=interface or self._interface,
timeout=timeout or self._connect_timeout,
disconnected_callback=self._disconnected_callback,
) as client:
entity = self._cache.get(client.address)
if not client:
if entity:
entity.connected = False
self.notify(BluetoothConnectionFailedEvent, entity)
raise AssertionError(f'Could not connect to the device {device}')
# Yield the BluetoothConnection object
self._connections[dev.address] = BluetoothConnection(
client=client,
device=dev,
loop=asyncio.get_event_loop(),
thread=threading.current_thread(),
close_event=close_event,
)
yield self._connections[dev.address]
async def _read(
self,
device: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
) -> bytearray:
"""
Asynchronously read the next chunk of raw bytes from a BLE device given
a service UUID.
"""
async with self._connect(device, interface, connect_timeout) as conn:
data = await conn.client.read_gatt_char(service_uuid)
return data
async def _write(
self,
device: str,
data: bytes,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
"""
Asynchronously write a chunk of raw bytes to a BLE device given a
service UUID.
"""
async with self._connect(device, interface, connect_timeout) as conn:
await conn.client.write_gatt_char(service_uuid, data)
async def _scan(
self,
duration: Optional[float] = None,
service_uuids: Optional[Collection[RawServiceClass]] = None,
) -> List[BluetoothDevice]:
"""
Asynchronously scan for BLE devices and return the discovered devices
as a list of :class:`platypush.entities.bluetooth.BluetoothDevice`
entities.
"""
with self._scan_lock:
timeout = duration or self.poll_interval
devices = await BleakScanner.discover(
adapter=self._interface,
timeout=timeout,
service_uuids=list(
map(str, service_uuids or self._service_uuids or [])
),
detection_callback=self._event_handler,
)
addresses = {dev.address for dev in devices}
return [
dev
for addr, dev in self._cache.items()
if addr.lower() in addresses and dev.reachable
]
def _close_active_connections(self):
"""
Terminates all active connections.
"""
connections = list(self._connections.values())
for conn in connections:
try:
self.disconnect(conn.device.address)
except Exception as e:
self.logger.warning(
'Error while disconnecting from %s: %s', conn.device.address, e
)
@override
def connect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
interface: Optional[str] = None,
timeout: Optional[float] = None,
):
timeout = timeout or self._connect_timeout
connected_event = threading.Event()
close_event = asyncio.Event()
loop = asyncio.new_event_loop()
def connect_thread():
"""
The connection thread. It wraps an asyncio loop with a connect
context manager.
"""
async def connect_wrapper():
"""
The asyncio connect wrapper.
"""
async with self._connect(device, interface, timeout, close_event):
connected_event.set()
await close_event.wait()
asyncio.set_event_loop(loop)
loop.run_until_complete(connect_wrapper())
# Initialize the loop and start the connection thread
loop = get_or_create_event_loop()
connector = threading.Thread(
target=connect_thread,
name=f'Bluetooth:connect@{device}',
)
connector.start()
# Wait for the connection to succeed
success = connected_event.wait(timeout=timeout)
assert success, f'Connection to {device} timed out'
@override
def disconnect(self, device: str, *_, **__):
# Get the device
loop = get_or_create_event_loop()
dev = loop.run_until_complete(self._get_device(device))
assert dev, f'Device {device} not found'
# Check if there are any active connections
connection = self._connections.get(dev.address, None)
assert connection, f'No active connections to the device {device} were found'
# Set the close event and wait for any connection thread to terminate
if connection.close_event:
connection.close_event.set()
if connection.thread and connection.thread.is_alive():
connection.thread.join(timeout=5)
assert not (
connection.thread and connection.thread.is_alive()
), f'Disconnection from {device} timed out'
@override
def scan(
self,
duration: Optional[float] = None,
service_uuids: Optional[Collection[RawServiceClass]] = None,
) -> List[BluetoothDevice]:
"""
Scan for Bluetooth devices nearby and return the results as a list of
entities.
:param duration: Scan duration in seconds (default: same as the plugin's
`poll_interval` configuration parameter)
:param service_uuids: List of service UUIDs to discover. Default: any.
"""
loop = get_or_create_event_loop()
return loop.run_until_complete(self._scan(duration, service_uuids))
@override
def read(
self,
device: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
) -> bytearray:
"""
:param device: Name or address of the device to read from.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None).
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
"""
loop = get_or_create_event_loop()
return loop.run_until_complete(
self._read(device, service_uuid, interface, connect_timeout)
)
@override
def write(
self,
device: str,
data: Union[bytes, bytearray],
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
get_or_create_event_loop().run_until_complete(
self._write(device, data, service_uuid, interface, connect_timeout)
)
async def listen(self):
"""
Main loop listener.
"""
self.logger.info('Starting BLE scanner')
device_addresses = set()
while not self.should_stop():
self._scan_enabled.wait()
if self.should_stop():
break
entities = await self._scan(service_uuids=self._service_uuids)
new_device_addresses = {e.external_id for e in entities}
missing_device_addresses = device_addresses - new_device_addresses
missing_devices = [
dev
for addr, dev in self._cache.items()
if addr in missing_device_addresses
]
for dev in missing_devices:
dev.reachable = False
dev.connected = False
self.notify(BluetoothDeviceLostEvent, dev)
device_addresses = new_device_addresses
@override
def run(self):
super().run()
self._main_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._main_loop)
try:
self._main_loop.run_until_complete(self.listen())
except Exception as e:
if not self.should_stop():
self.logger.warning('The main loop failed unexpectedly: %s', e)
self.logger.exception(e)
finally:
try:
# Try and release the scan lock if acquired
self._scan_lock.release()
except Exception:
pass
@override
def stop(self):
"""
Upon stop request, it stops any pending scans and closes all active
connections.
"""
self._close_active_connections()
if self._main_loop and self._main_loop.is_running():
self._main_loop.stop()
self.logger.info('Stopped the BLE scanner')
# vim:sw=4:ts=4:et:

View File

@ -1,11 +1,10 @@
import json
import struct
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict, List, Optional
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.uuids import uuidstr_to_str
from bluetooth_numbers import company
# pylint: disable=no-name-in-module
@ -13,7 +12,7 @@ from TheengsGateway._decoder import decodeBLE, getAttribute, getProperties
from platypush.entities import Entity
from platypush.entities.batteries import Battery
from platypush.entities.bluetooth import BluetoothDevice
from platypush.entities.bluetooth import BluetoothDevice, BluetoothService
from platypush.entities.contact import ContactSensor
from platypush.entities.electricity import (
CurrentSensor,
@ -33,6 +32,8 @@ from platypush.entities.temperature import TemperatureSensor
from platypush.entities.time import TimeDurationSensor
from platypush.entities.weight import WeightSensor
from .._model import Protocol, ServiceClass
@dataclass
class TheengsEntity:
@ -42,7 +43,7 @@ class TheengsEntity:
data: dict = field(default_factory=dict)
properties: dict = field(default_factory=dict)
brand: Optional[str] = None
manufacturer: Optional[str] = None
model: Optional[str] = None
model_id: Optional[str] = None
@ -196,6 +197,34 @@ _value_type_to_entity: Dict[type, Callable[[Any, Dict[str, Any]], Entity]] = {
}
def _parse_services(device: BLEDevice) -> List[BluetoothService]:
"""
:param device: The target device.
:return: The parsed BLE services as a list of
:class:`platypush.entities.bluetooth.BluetoothService`.
"""
services: List[BluetoothService] = []
for srv in device.metadata.get('uuids', []):
try:
uuid = BluetoothService.to_uuid(srv)
except (TypeError, ValueError):
# Not a valid UUID.
continue
srv_cls = ServiceClass.get(uuid)
services.append(
BluetoothService(
id=f'{device.address}:{uuid}',
uuid=uuid,
name=str(srv_cls),
protocol=Protocol.L2CAP,
is_ble=True,
)
)
return services
def device_to_entity(device: BLEDevice, data: AdvertisementData) -> BluetoothDevice:
"""
Convert the data received from a Bluetooth advertisement packet into a
@ -205,12 +234,26 @@ def device_to_entity(device: BLEDevice, data: AdvertisementData) -> BluetoothDev
"""
theengs_entity = _parse_advertisement_data(data)
props = (device.details or {}).get('props', {})
manufacturer = theengs_entity.manufacturer or company.get(
list(device.metadata['manufacturer_data'].keys())[0]
if device.metadata.get('manufacturer_data', {})
else None
)
parent_entity = BluetoothDevice(
id=device.address,
model=theengs_entity.model,
brand=theengs_entity.brand,
reachable=True,
**parse_device_args(device),
supports_ble=True,
supports_legacy=False,
address=device.address,
name=device.name or device.address,
connected=props.get('Connected', False),
rssi=device.rssi,
tx_power=props.get('TxPower'),
manufacturer=manufacturer,
children=_parse_services(device),
)
parsed_entities = {
@ -239,6 +282,7 @@ def device_to_entity(device: BLEDevice, data: AdvertisementData) -> BluetoothDev
entity.id = f'{parent_entity.id}:{prop}'
entity.name = prop
parent_entity.children.append(entity)
entity.parent = parent_entity
return parent_entity
@ -250,7 +294,7 @@ def _parse_advertisement_data(data: AdvertisementData) -> TheengsEntity:
maps the parsed attributes.
"""
entity_args, properties, brand, model, model_id = ({}, {}, None, None, None)
entity_args, properties, manufacturer, model, model_id = ({}, {}, None, None, None)
if data.service_data:
parsed_data = list(data.service_data.keys())[0]
@ -286,67 +330,7 @@ def _parse_advertisement_data(data: AdvertisementData) -> TheengsEntity:
return TheengsEntity(
data=entity_args,
properties=properties,
brand=brand,
manufacturer=manufacturer,
model=model,
model_id=model_id,
)
def parse_device_args(device: BLEDevice) -> Dict[str, Any]:
"""
:param device: The device to parse.
:return: The mapped device arguments required to initialize a
:class:`platypush.entity.bluetooth.BluetoothDevice` or
:class:`platypush.message.event.bluetooth.BluetoothDeviceEvent`
object.
"""
props = (device.details or {}).get('props', {})
return {
'name': device.name or device.address,
'connected': props.get('Connected', False),
'paired': props.get('Paired', False),
'blocked': props.get('Blocked', False),
'trusted': props.get('Trusted', False),
'rssi': device.rssi,
'tx_power': props.get('TxPower'),
'uuids': {
uuid: uuidstr_to_str(uuid) for uuid in device.metadata.get('uuids', [])
},
'manufacturers': {
manufacturer_id: company.get(manufacturer_id, 'Unknown')
for manufacturer_id in sorted(
device.metadata.get('manufacturer_data', {}).keys()
)
},
'manufacturer_data': _parse_manufacturer_data(device),
'service_data': _parse_service_data(device),
}
def _parse_manufacturer_data(device: BLEDevice) -> Dict[int, str]:
"""
:param device: The device to parse.
:return: The manufacturer data as a ``manufacturer_id -> hex_string``
mapping.
"""
return {
manufacturer_id: ''.join([f'{x:02x}' for x in value])
for manufacturer_id, value in device.metadata.get(
'manufacturer_data', {}
).items()
}
def _parse_service_data(device: BLEDevice) -> Dict[str, str]:
"""
:param device: The device to parse.
:return: The service data as a ``service_uuid -> hex_string`` mapping.
"""
return {
service_uuid: ''.join([f'{x:02x}' for x in value])
for service_uuid, value in (device.details or {})
.get('props', {})
.get('ServiceData', {})
.items()
}

View File

@ -0,0 +1,207 @@
from abc import ABC, abstractmethod
from collections import defaultdict
from threading import RLock
from typing import Any, Dict, Iterable, Optional, Tuple, Union
from typing_extensions import override
from platypush.entities.bluetooth import BluetoothDevice
from ._model import MajorDeviceClass
class BaseCache(ABC):
"""
Base cache class for Bluetooth devices and entities.
"""
_by_address: Dict[str, Any]
""" Device cache by address. """
_by_name: Dict[str, Any]
""" Device cache by name. """
def __init__(self):
self._by_address = {}
self._by_name = {}
self._insert_locks = defaultdict(RLock)
""" Locks for inserting devices into the cache. """
@property
@abstractmethod
def _address_field(self) -> str:
"""
Name of the field that contains the address of the device.
"""
@property
@abstractmethod
def _name_field(self) -> str:
"""
Name of the field that contains the name of the device.
"""
def get(self, device: str) -> Optional[Any]:
"""
Get a device by address or name.
"""
dev = self._by_address.get(device)
if not dev:
dev = self._by_name.get(device)
return dev
def add(self, device: Any) -> Any:
"""
Cache a device.
"""
with self._insert_locks[device.address]:
addr = getattr(device, self._address_field)
name = getattr(device, self._name_field)
self._by_address[addr] = device
if name:
self._by_name[name] = device
return device
def keys(self) -> Iterable[str]:
"""
:return: All the cached device addresses.
"""
return list(self._by_address.keys())
def values(self) -> Iterable[Any]:
"""
:return: All the cached device entities.
"""
return list(self._by_address.values())
def items(self) -> Iterable[Tuple[str, Any]]:
"""
:return: All the cached items, as ``(address, device)`` tuples.
"""
return list(self._by_address.items())
def __contains__(self, device: str) -> bool:
"""
:return: ``True`` if the entry is cached, ``False`` otherwise.
"""
return self.get(device) is not None
class EntityCache(BaseCache):
"""
Cache used to store scanned Bluetooth devices as :class:`BluetoothDevice`.
"""
_by_address: Dict[str, BluetoothDevice]
_by_name: Dict[str, BluetoothDevice]
@property
@override
def _address_field(self) -> str:
return 'address'
@property
@override
def _name_field(self) -> str:
return 'name'
@override
def get(self, device: Union[str, BluetoothDevice]) -> Optional[BluetoothDevice]:
dev_filter = device.address if isinstance(device, BluetoothDevice) else device
return super().get(dev_filter)
@override
def add(self, device: BluetoothDevice) -> BluetoothDevice:
with self._insert_locks[device.address]:
existing_device = self.get(device)
if existing_device:
self._merge_properties(device, existing_device)
self._merge_children(device, existing_device)
device = existing_device
return super().add(device)
@override
def values(self) -> Iterable[BluetoothDevice]:
return super().values()
@override
def items(self) -> Iterable[Tuple[str, BluetoothDevice]]:
return super().items()
@override
def __contains__(self, device: Union[str, BluetoothDevice]) -> bool:
"""
Override the default ``__contains__`` to support lookup by partial
:class:`BluetoothDevice` objects.
"""
return super().__contains__(device)
def _merge_properties(
self, device: BluetoothDevice, existing_device: BluetoothDevice
):
"""
Coalesce the properties of the two device representations.
"""
# Coalesce the major device class
if existing_device.major_device_class == MajorDeviceClass.UNKNOWN:
existing_device.major_device_class = device.major_device_class
# Coalesce the other device and service classes
for attr in ('major_service_classes', 'minor_device_classes'):
setattr(
existing_device,
attr,
list(
{
*getattr(existing_device, attr, []),
*getattr(device, attr, []),
}
),
)
# Coalesce mutually exclusive supports_* flags
for attr in ('supports_ble', 'supports_legacy'):
if not getattr(existing_device, attr, None):
setattr(existing_device, attr, getattr(device, attr, None) or False)
# Merge the connected property
existing_device.connected = (
device.connected
if device.connected is not None
else existing_device.connected
)
# Coalesce other manager-specific properties
for attr in ('rssi', 'tx_power'):
if getattr(existing_device, attr, None) is None:
setattr(existing_device, attr, getattr(device, attr, None))
# Merge the data and meta dictionaries
for attr in ('data', 'meta'):
setattr(
existing_device,
attr,
{
**(getattr(existing_device, attr) or {}),
**(getattr(device, attr) or {}),
},
)
def _merge_children(
self, device: BluetoothDevice, existing_device: BluetoothDevice
):
"""
Merge the device's children upon set without overwriting the
existing ones.
"""
# Map of the existing children
existing_children = {
child.external_id: child for child in existing_device.children
}
# Map of the new children
new_children = {child.id: child for child in device.children}
# Merge the existing children with the new ones without overwriting them
existing_children.update(new_children)
existing_device.children = list(existing_children.values())

View File

@ -0,0 +1,4 @@
from .sender import FileSender
__all__ = ["FileSender"]

View File

@ -0,0 +1,105 @@
import logging
import os
from typing import Any, Type
from PyOBEX.client import Client
from platypush.context import get_bus
from platypush.entities.bluetooth import BluetoothDevice
from platypush.message.event.bluetooth import (
BluetoothConnectionFailedEvent,
BluetoothDeviceEvent,
BluetoothFileSentEvent,
BluetoothFileTransferCancelledEvent,
BluetoothFileTransferStartedEvent,
)
from platypush.plugins.bluetooth._legacy import LegacyManager
from platypush.plugins.bluetooth.model import ServiceClass
# pylint: disable=too-few-public-methods
class FileSender:
"""
Wrapper for the Bluetooth file send OBEX service.
"""
def __init__(self, scanner: LegacyManager):
self._scanner = scanner
self.logger = logging.getLogger(__name__)
def send_file(
self,
file: str,
device: str,
data: bytes,
):
"""
Send a file to a device.
:param file: Name/path of the file to send.
:param device: Name or address of the device to send the file to.
:param data: File data.
"""
dev = self._scanner.get_device(device)
service = dev.known_services.get(ServiceClass.OBEX_OBJECT_PUSH)
assert service, (
f'The device {device} does not expose the service '
f'{str(ServiceClass.OBEX_OBJECT_PUSH)}'
)
port = service.port
client = self._connect(dev, port)
self._post_event(BluetoothFileTransferStartedEvent, dev, file=file)
self._send_file(client, dev, file, data)
def _send_file(
self,
client: Client,
dev: BluetoothDevice,
file: str,
data: bytes,
):
filename = os.path.basename(file)
try:
client.put(filename, data)
self._post_event(BluetoothFileSentEvent, dev, file=file)
except Exception as e:
self._post_event(
BluetoothFileTransferCancelledEvent,
dev,
reason=str(e),
file=file,
)
raise AssertionError(
f'Failed to send file {file} to device {dev.address}: {e}'
) from e
finally:
client.disconnect()
def _connect(self, dev: BluetoothDevice, port: str) -> Client:
client = Client(dev.address, port)
try:
client.connect()
assert (
client.connection_id is not None
), 'Could not establish a connection to the device'
except Exception as e:
self._post_event(BluetoothConnectionFailedEvent, dev, reason=str(e))
raise AssertionError(
f'Connection to device {dev.address} failed: {e}'
) from e
return client
def _post_event(
self,
event_type: Type[BluetoothDeviceEvent],
device: BluetoothDevice,
**event_args: Any,
):
get_bus().post(event_type.from_device(device, **event_args))

View File

@ -0,0 +1,3 @@
from ._manager import LegacyManager
__all__ = ["LegacyManager"]

View File

@ -0,0 +1,3 @@
from ._base import LegacyManager
__all__ = ["LegacyManager"]

View File

@ -0,0 +1,346 @@
from collections import defaultdict
from contextlib import contextmanager
from queue import Empty, Queue
from threading import Event, RLock, Thread, current_thread
from typing import (
Dict,
Final,
Generator,
List,
Optional,
Union,
)
from typing_extensions import override
import bluetooth
from platypush.entities.bluetooth import BluetoothDevice, BluetoothService
from platypush.message.event.bluetooth import (
BluetoothConnectionFailedEvent,
BluetoothDeviceConnectedEvent,
BluetoothDeviceDisconnectedEvent,
)
from ..._manager import BaseBluetoothManager
from ..._types import RawServiceClass
from .._model import BluetoothDeviceBuilder
from ._connection import BluetoothConnection
from ._service import ServiceDiscoverer
from ._types import ConnectionId
class LegacyManager(BaseBluetoothManager):
"""
Scanner for Bluetooth non-low-energy devices.
"""
_service_discovery_timeout: Final[int] = 30
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._connections: Dict[ConnectionId, BluetoothConnection] = {}
""" Maps (address, port) pairs to Bluetooth connections. """
self._connection_locks: Dict[ConnectionId, RLock] = defaultdict(RLock)
""" Maps (address, port) pairs to connection locks. """
self._service_scanned_devices: Dict[str, bool] = {}
""" Maps the addresses of the devices whose services have been scanned. """
def get_device(
self,
device: str,
scan_duration: Optional[int] = None,
_fail_if_not_cached: bool = False,
) -> BluetoothDevice:
"""
Get/discover a device by its address or name.
:param device: Device address or name.
:param scan_duration: Overrides the duration of the scan.
:param _fail_if_not_cached: Throw an assertion error if the device
hasn't been cached yet.
"""
duration = scan_duration or self.poll_interval
dev = self._cache.get(device)
if dev:
return dev # If it's already cached, just return it.
assert not _fail_if_not_cached, f'Device "{device}" not found'
# Otherwise, scan for the device.
self.logger.info('Scanning for device "%s"...', device)
self.scan(duration=duration)
# Run the method again, but this time fail if the device has not been
# found in the latest scan.
return self.get_device(device, scan_duration, _fail_if_not_cached=True)
def _get_matching_services(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
) -> List[BluetoothService]:
"""
Given a device and a port or service UUID, return a list of matching
services.
"""
assert port or service_uuid, 'Please specify at least one of port/service_uuid'
dev = self.get_device(device)
assert dev, f'Device "{device}" not found'
matching_services = []
if port:
matching_services = [srv for srv in dev.services if srv.port == port]
elif service_uuid:
uuid = BluetoothService.to_uuid(service_uuid)
matching_services = [srv for srv in dev.services if uuid == srv.uuid]
return matching_services
def _connect_thread(
self,
conn_queue: Queue[BluetoothConnection],
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
):
"""
Connection thread that asynchronously pushes a
:class:`BluetoothConnection` object to a queue when connected, so the
caller can wait for the connection to complete and handle timeouts.
"""
dev = self.get_device(device)
matching_services = self._get_matching_services(device, port, service_uuid)
assert matching_services, (
f'No services found on {dev.address} for '
f'UUID={service_uuid} port={port}'
)
conn = BluetoothConnection(
address=dev.address,
service=matching_services[0],
thread=current_thread(),
)
existing_conn = self._connections.get(conn.key)
if existing_conn and existing_conn.socket:
conn = existing_conn
else:
with self._connection_locks[conn.key]:
addr = conn.address
port_ = conn.service.port
self.logger.info(
'Opening connection to device %s on port %s', addr, port_
)
# Connect to the specified address and port.
conn.socket.connect((addr, port_))
self.logger.info('Connected to device %s on port %s', addr, port_)
self._connections[conn.key] = conn
conn_queue.put_nowait(conn)
@contextmanager
def _connect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
timeout: Optional[float] = None,
) -> Generator[BluetoothConnection, None, None]:
"""
Wraps the connection thread in a context manager with timeout support.
"""
dev = self.get_device(device)
# Queue where the connection object is pushed once the socket is ready
conn_queue: Queue[BluetoothConnection] = Queue()
# Start the connection thread
conn_thread = Thread(
target=self._connect_thread,
name=f'Bluetooth:connect@{device}',
args=(conn_queue, device, port, service_uuid),
)
conn_thread.start()
# Wait for the connection object
timeout = timeout or self._connect_timeout
try:
conn = conn_queue.get(timeout=timeout)
except Empty as e:
dev.connected = False
self.notify(BluetoothConnectionFailedEvent, dev, reason=str(e))
raise AssertionError(f'Connection to {device} timed out') from e
dev.connected = True
self.notify(BluetoothDeviceConnectedEvent, dev)
yield conn
# Close the connection once the context is over
with self._connection_locks[conn.key]:
conn.close()
self._connections.pop(conn.key, None)
dev.connected = False
self.notify(BluetoothDeviceDisconnectedEvent, dev)
@override
def connect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
interface: Optional[str] = None,
timeout: Optional[float] = None,
):
connected = Event()
def connect_thread():
with self._connect(device, port, service_uuid) as conn:
connected.set()
conn.stop_event.wait()
self.logger.info('Connection to %s successfully terminated', conn.address)
# Start the connection thread
Thread(
target=connect_thread,
name=f'Bluetooth:connect:wrapper@{device}',
).start()
# Wait for the connected event
timeout = timeout or self._connect_timeout
conn_success = connected.wait(timeout=timeout)
assert conn_success, f'Connection to {device} timed out'
@override
def disconnect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
):
matching_connections = [
conn
for conn in self._connections.values()
if conn.address == device
and (port is None or conn.service.port == port)
and (service_uuid is None or conn.service.uuid == service_uuid)
]
assert matching_connections, f'No active connections found to {device}'
for conn in matching_connections:
conn.close()
@override
def scan(self, duration: Optional[float] = None) -> List[BluetoothDevice]:
duration = duration or self.poll_interval
assert duration, 'Scan duration must be set'
duration = int(max(duration, 1))
with self._scan_lock:
# Discover all devices.
try:
info = bluetooth.discover_devices(
duration=duration, lookup_names=True, lookup_class=True
)
except IOError as e:
self.logger.warning('Could not discover devices: %s', e)
# Wait a bit before a potential retry
self._stop_event.wait(timeout=1)
return []
# Pre-fill the services for the devices that have already been scanned.
services: Dict[str, List[BluetoothService]] = {
addr: self._cache.get(addr).services # type: ignore
for addr, _, __ in info
if self._cache.get(addr) is not None
}
# Check if there are any devices that have not been scanned yet.
unknown_devices = [
addr
for addr, _, __ in info
if not self._service_scanned_devices.get(addr, False)
]
# Discover the services for the devices that have not been scanned.
if unknown_devices:
services.update(
ServiceDiscoverer().discover(
*unknown_devices, timeout=self._service_discovery_timeout
)
)
# Initialize the BluetoothDevice objects.
devices = {
addr: BluetoothDeviceBuilder.build(
address=addr,
name=name,
raw_class=class_,
services=services.get(addr, []),
)
for addr, name, class_ in info
}
for dev in devices.values():
self._service_scanned_devices[dev.address] = True
self._device_queue.put_nowait(dev)
return list(devices.values())
@override
def read(
self,
device: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
size: int = 1024,
) -> bytearray:
"""
:param size: Number of bytes to read.
"""
with self._connect(
device, service_uuid=service_uuid, timeout=connect_timeout
) as conn:
try:
return conn.socket.recv(size)
except bluetooth.BluetoothError as e:
raise AssertionError(f'Error reading from {device}: {e}') from e
@override
def write(
self,
device: str,
data: Union[bytes, bytearray],
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
with self._connect(
device, service_uuid=service_uuid, timeout=connect_timeout
) as conn:
try:
return conn.socket.send(data)
except bluetooth.BluetoothError as e:
raise AssertionError(f'Error reading from {device}: {e}') from e
@override
def run(self):
super().run()
self.logger.info('Starting legacy Bluetooth scanner')
while not self.should_stop():
scan_enabled = self._scan_enabled.wait(timeout=1)
if scan_enabled:
self.scan(duration=self.poll_interval)
@override
def stop(self):
# Close any active connections
for conn in list(self._connections.values()):
conn.close(timeout=5)
self.logger.info('Stopped the Bluetooth legacy scanner')

View File

@ -0,0 +1,64 @@
from dataclasses import dataclass, field
from logging import getLogger
from threading import Event, Thread, current_thread
from typing import Optional
import bluetooth
from platypush.entities.bluetooth import BluetoothService
from ._types import ConnectionId
_logger = getLogger(__name__)
@dataclass
class BluetoothConnection:
"""
Models a connection to a Bluetooth device.
"""
address: str
service: BluetoothService
socket: bluetooth.BluetoothSocket = field(
init=False, default_factory=bluetooth.BluetoothSocket
)
thread: Thread = field(default_factory=current_thread)
stop_event: Event = field(default_factory=Event)
def __post_init__(self):
"""
Initialize the Bluetooth socket with the given protocol.
"""
self.socket = bluetooth.BluetoothSocket(self.service.protocol.value)
@property
def key(self) -> ConnectionId:
return self.address, self.service.port
def close(self, timeout: Optional[float] = None):
_logger.info('Closing connection to %s', self.address)
# Set the stop event
self.stop_event.set()
# Close the socket
if self.socket:
try:
self.socket.close()
except Exception as e:
_logger.warning(
'Failed to close Bluetooth socket on %s: %s',
self.address,
e,
)
# Avoid deadlocking by waiting for our own thread to terminate
if current_thread() is self.thread:
return
# Wait for the connection thread to terminate
if self.thread and self.thread.is_alive():
self.thread.join(timeout=timeout)
if self.thread and self.thread.is_alive():
_logger.warning('Connection to %s still alive after closing', self.address)

View File

@ -0,0 +1,64 @@
from concurrent.futures import ProcessPoolExecutor
from logging import getLogger
from typing import Dict, List, Optional
import bluetooth
from platypush.entities.bluetooth import BluetoothService
from .._model import BluetoothServicesBuilder
# pylint: disable=too-few-public-methods
class ServiceDiscoverer:
"""
Runs the service discovery processes in a pool.
"""
def __init__(self, max_workers: int = 4, timeout: float = 30):
self._max_workers = max_workers
self._timeout = timeout
self.logger = getLogger(__name__)
def _discover(self, address: str) -> List[BluetoothService]:
"""
Inner implementation of the service discovery for a specific device.
"""
self.logger.info("Discovering services for %s...", address)
try:
return BluetoothServicesBuilder.build(
bluetooth.find_service(address=address)
)
except Exception as e:
self.logger.warning(
"Failed to discover services for the device %s: %s", address, e
)
return []
finally:
self.logger.info("Service discovery for %s completed", address)
def discover(
self, *addresses: str, timeout: Optional[float] = None
) -> Dict[str, List[BluetoothService]]:
"""
Discover the services for the given addresses. Discovery will happen in
parallel through a process pool.
:param addresses: The addresses to scan.
:param timeout: The timeout in seconds.
:return: An ``{address: [services]}`` dictionary with the discovered
services per device.
"""
discovered_services: Dict[str, List[BluetoothService]] = {}
with ProcessPoolExecutor(max_workers=self._max_workers) as executor:
try:
for i, services in enumerate(
executor.map(
self._discover, addresses, timeout=timeout or self._timeout
)
):
discovered_services[addresses[i]] = services
except TimeoutError:
self.logger.warning("Service discovery timed out.")
return discovered_services

View File

@ -0,0 +1,5 @@
from typing import Tuple
ConnectionId = Tuple[str, int]
""" (address, port) pair. """

View File

@ -0,0 +1,8 @@
from ._device import BluetoothDeviceBuilder
from ._services import BluetoothServicesBuilder
__all__ = [
"BluetoothDeviceBuilder",
"BluetoothServicesBuilder",
]

View File

@ -0,0 +1,76 @@
from typing import Iterable, List, Optional
from bluetooth_numbers import oui
from platypush.entities.bluetooth import BluetoothDevice, BluetoothService
from platypush.plugins.bluetooth.model import (
MajorServiceClass,
MajorDeviceClass,
MinorDeviceClass,
)
# pylint: disable=too-few-public-methods
class BluetoothDeviceBuilder:
"""
:class:`platypush.entity.bluetooth.BluetoothDevice` entity builder from the
raw pybluez data.
"""
@classmethod
def build(
cls,
address: str,
name: str,
raw_class: int,
services: Optional[Iterable[BluetoothService]] = None,
) -> BluetoothDevice:
"""
Builds a :class:`platypush.entity.bluetooth.BluetoothDevice` from the
raw pybluez data.
"""
return BluetoothDevice(
id=address,
address=address,
name=name,
major_service_classes=cls._parse_major_service_classes(raw_class),
major_device_class=cls._parse_major_device_class(raw_class),
minor_device_classes=cls._parse_minor_device_classes(raw_class),
manufacturer=cls._parse_manufacturer(address),
supports_legacy=True,
supports_ble=False,
children=services,
)
@staticmethod
def _parse_major_device_class(raw_class: int) -> MajorDeviceClass:
"""
Parse the device major class from the raw exposed class value.
"""
device_classes = [
cls for cls in MajorDeviceClass if cls.value.matches(raw_class)
]
return device_classes[0] if device_classes else MajorDeviceClass.UNKNOWN
@staticmethod
def _parse_minor_device_classes(raw_class: int) -> List[MinorDeviceClass]:
"""
Parse the device minor classes from the raw exposed class value.
"""
return [cls for cls in MinorDeviceClass if cls.value.matches(raw_class)]
@staticmethod
def _parse_major_service_classes(raw_class: int) -> List[MajorServiceClass]:
"""
Parse the device major service classes from the raw exposed class value.
"""
return [cls for cls in MajorServiceClass if cls.value.matches(raw_class)]
@staticmethod
def _parse_manufacturer(address: str) -> Optional[str]:
"""
Parse the device manufacturer name from the raw MAC address.
"""
return oui.get(':'.join(address.split(':')[:3]).upper())

View File

@ -0,0 +1,47 @@
from typing import Any, Dict, Iterable, List
from platypush.entities.bluetooth import BluetoothService
from ..._model import ServiceClass
# pylint: disable=too-few-public-methods
class BluetoothServicesBuilder:
"""
Builds a list of :class:`platypush.entities.bluetooth.BluetoothService`
entities from the list of dictionaries returned by
``bluetooth.find_services()``.
"""
@classmethod
def build(cls, services: Iterable[Dict[str, Any]]) -> List[BluetoothService]:
"""
Parse the services exposed by the device from the raw pybluez data.
"""
parsed_services = {}
for srv in services:
service_args = {
key: srv.get(key) for key in ['name', 'description', 'port', 'protocol']
}
classes = srv.get('service-classes', [])
versions = dict(srv.get('profiles', []))
for srv_cls in classes:
uuid = BluetoothService.to_uuid(srv_cls)
parsed_service = parsed_services[uuid] = BluetoothService(
id=f'{srv["host"]}::{srv_cls}',
uuid=uuid,
version=versions.get(srv_cls),
**service_args,
)
# Ensure that the service name is always set
if not parsed_service.name:
parsed_service.name = (
str(parsed_service.service_class)
if parsed_service.service_class != ServiceClass.UNKNOWN
else f'[{parsed_service.uuid}]'
)
return list(parsed_services.values())

View File

@ -0,0 +1,163 @@
from abc import ABC, abstractmethod
import logging
from queue import Queue
import threading
from typing import Collection, Optional, Type, Union
from platypush.context import get_bus
from platypush.entities.bluetooth import BluetoothDevice
from platypush.message.event.bluetooth import BluetoothDeviceEvent
from ._cache import EntityCache
from ._types import RawServiceClass
class BaseBluetoothManager(ABC, threading.Thread):
"""
Abstract interface for Bluetooth managers.
"""
def __init__(
self,
interface: str,
poll_interval: float,
connect_timeout: float,
stop_event: threading.Event,
scan_lock: threading.RLock,
scan_enabled: threading.Event,
device_queue: Queue[BluetoothDevice],
service_uuids: Optional[Collection[RawServiceClass]] = None,
device_cache: Optional[EntityCache] = None,
**kwargs,
):
"""
:param interface: The Bluetooth interface to use.
:param poll_interval: Scan interval in seconds.
:param connect_timeout: Connection timeout in seconds.
:param stop_event: Event used to synchronize on whether we should stop the plugin.
:param scan_lock: Lock to synchronize scanning access to the Bluetooth device.
:param scan_enabled: Event used to enable/disable scanning.
:param device_queue: Queue used by the ``EventHandler`` to publish
updates with the new parsed device entities.
:param device_cache: Cache used to keep track of discovered devices.
"""
kwargs['name'] = f'Bluetooth:{self.__class__.__name__}'
super().__init__(**kwargs)
self.logger = logging.getLogger(__name__)
self.poll_interval = poll_interval
self._interface: Optional[str] = interface
self._connect_timeout: float = connect_timeout
self._service_uuids: Collection[RawServiceClass] = service_uuids or []
self._stop_event = stop_event
self._scan_lock = scan_lock
self._scan_enabled = scan_enabled
self._device_queue = device_queue
self._cache = device_cache or EntityCache()
""" Cache of discovered devices. """
def notify(
self, event_type: Type[BluetoothDeviceEvent], device: BluetoothDevice, **kwargs
):
"""
Notify about a device update event by posting a
:class:`platypush.message.event.bluetooth.BluetoothDeviceEvent` event on
the bus and pushing the updated entity upstream.
"""
get_bus().post(event_type.from_device(device=device, **kwargs))
self._device_queue.put_nowait(device)
def should_stop(self) -> bool:
return self._stop_event.is_set()
@abstractmethod
def connect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
interface: Optional[str] = None,
timeout: Optional[float] = None,
):
"""
Pair and connect to a device by address or name.
:param device: The device address or name.
:param port: The Bluetooth port to use.
:param service_uuid: The service UUID to connect to.
:param interface: The Bluetooth interface to use (it overrides the
default ``interface``).
:param timeout: The connection timeout in seconds (it overrides the
default ``connect_timeout``).
"""
@abstractmethod
def disconnect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
):
"""
Close an active connection to a device.
:param device: The device address or name.
:param port: If connected to a non-BLE device, the optional port to
disconnect. Either ``port`` or ``service_uuid`` is required for
non-BLE devices.
:param service_uuid: The UUID of the service to disconnect from.
"""
@abstractmethod
def scan(self, duration: Optional[float] = None) -> Collection[BluetoothDevice]:
"""
Scan for Bluetooth devices nearby and return the results as a list of
entities.
:param duration: Scan duration in seconds (default: same as the plugin's
`poll_interval` configuration parameter)
"""
@abstractmethod
def read(
self,
device: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
) -> bytearray:
"""
:param device: Name or address of the device to read from.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None).
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
"""
@abstractmethod
def write(
self,
device: str,
data: Union[bytes, bytearray],
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
"""
:param device: Name or address of the device to read from.
:param data: Raw data to be sent.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None)
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
"""
@abstractmethod
def stop(self):
"""
Stop any pending tasks and terminate the thread.
"""
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,11 @@
from ._classes import MajorDeviceClass, MajorServiceClass, MinorDeviceClass
from ._protocol import Protocol
from ._service import ServiceClass
__all__ = [
"MajorDeviceClass",
"MajorServiceClass",
"MinorDeviceClass",
"Protocol",
"ServiceClass",
]

View File

@ -0,0 +1,9 @@
from ._device import MajorDeviceClass, MinorDeviceClass
from ._service import MajorServiceClass
__all__ = [
"MajorDeviceClass",
"MinorDeviceClass",
"MajorServiceClass",
]

View File

@ -0,0 +1,59 @@
from enum import Enum
from dataclasses import dataclass
from typing import Optional
class BaseBluetoothClass(Enum):
"""
Base enum to model Bluetooth device/service classes.
"""
def __repr__(self) -> str:
"""
:return: The enum class formatted as ``<name: <value>``.
"""
return f'<{self.__class__.__name__}.{self.name}: {str(self)}>'
def __str__(self) -> str:
"""
:return Only the readable string value of the class.
"""
return self.value.name
@dataclass
class ClassProperty:
"""
Models a Bluetooth class property.
Given a Bluetooth class as a 24-bit unsigned integer, this class models the
filter that should be applied to the class to tell if the device exposes the
property.
"""
name: str
""" The name of the property. """
bitmask: int
""" Bitmask used to select the property bits from the class. """
bit_shift: int = 0
""" Number of bits to shift the class value after applying the bitmask. """
match_value: int = 1
"""
This should be the result of the bitwise filter for the property to match.
"""
parent: Optional[BaseBluetoothClass] = None
"""
Parent class property, if this is a minor property. If this is the case,
then the advertised class value should also match the parent property.
"""
def matches(self, cls: int) -> bool:
"""
Match function.
:param cls: The Bluetooth composite class value.
:return: True if the class matches the property.
"""
if self.parent and not self.parent.value.matches(cls):
return False
return ((cls & self.bitmask) >> self.bit_shift) == self.match_value

View File

@ -0,0 +1,8 @@
from ._major import MajorDeviceClass
from ._minor import MinorDeviceClass
__all__ = [
"MajorDeviceClass",
"MinorDeviceClass",
]

View File

@ -0,0 +1,20 @@
from .._base import BaseBluetoothClass, ClassProperty
class MajorDeviceClass(BaseBluetoothClass):
"""
Models Bluetooth major device classes - see
https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Assigned%20Numbers.pdf,
Section 2.8.2
"""
UNKNOWN = ClassProperty('Unknown', 0x1F00, 8, 0b0000)
COMPUTER = ClassProperty('Computer', 0x1F00, 8, 0b0001)
PHONE = ClassProperty('Phone', 0x1F00, 8, 0b0010)
AP = ClassProperty('LAN / Network Access Point', 0x1F00, 8, 0b0011)
MULTIMEDIA = ClassProperty('Audio / Video', 0x1F00, 8, 0b0100)
PERIPHERAL = ClassProperty('Peripheral', 0x1F00, 8, 0b0101)
IMAGING = ClassProperty('Imaging', 0x1F00, 8, 0b0110)
WEARABLE = ClassProperty('Wearable', 0x1F00, 8, 0b0111)
TOY = ClassProperty('Toy', 0x1F00, 8, 0b1000)
HEALTH = ClassProperty('Health', 0x1F00, 8, 0b1001)

View File

@ -0,0 +1,255 @@
from .._base import BaseBluetoothClass, ClassProperty
from ._major import MajorDeviceClass
class MinorDeviceClass(BaseBluetoothClass):
"""
Models Bluetooth minor device classes - see
https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Assigned%20Numbers.pdf,
Sections 2.8.2.1 - 2.8.2.9
"""
# Computer classes
COMPUTER_UNKNOWN = ClassProperty(
'Unknown', 0xFC, 2, 0b000, MajorDeviceClass.COMPUTER
)
COMPUTER_DESKTOP = ClassProperty(
'Desktop Workstation', 0xFC, 2, 0b001, MajorDeviceClass.COMPUTER
)
COMPUTER_SERVER = ClassProperty('Server', 0xFC, 2, 0b010, MajorDeviceClass.COMPUTER)
COMPUTER_LAPTOP = ClassProperty('Laptop', 0xFC, 2, 0b011, MajorDeviceClass.COMPUTER)
COMPUTER_HANDHELD_PDA = ClassProperty(
'Handheld PDA', 0xFC, 2, 0b100, MajorDeviceClass.COMPUTER
)
COMPUTER_PALM_PDA = ClassProperty(
'Palm-sized PDA', 0xFC, 2, 0b101, MajorDeviceClass.COMPUTER
)
COMPUTER_WEARABLE = ClassProperty(
'Wearable Computer', 0xFC, 2, 0b110, MajorDeviceClass.COMPUTER
)
# Phone classes
PHONE_UNKNOWN = ClassProperty('Unknown', 0xFC, 2, 0b000, MajorDeviceClass.PHONE)
PHONE_CELLULAR = ClassProperty('Cellular', 0xFC, 2, 0b001, MajorDeviceClass.PHONE)
PHONE_CORDLESS = ClassProperty('Cordless', 0xFC, 2, 0b010, MajorDeviceClass.PHONE)
PHONE_SMARTPHONE = ClassProperty(
'Smartphone', 0xFC, 2, 0b011, MajorDeviceClass.PHONE
)
PHONE_WIRED_MODEM = ClassProperty(
'Wired Modem', 0xFC, 2, 0b100, MajorDeviceClass.PHONE
)
PHONE_ISDN_ACCESS = ClassProperty(
'ISDN Access Point', 0xFC, 2, 0b101, MajorDeviceClass.PHONE
)
# LAN / Access Point classes
AP_USAGE_0 = ClassProperty('Fully Available', 0xE0, 5, 0b000, MajorDeviceClass.AP)
AP_USAGE_1_17 = ClassProperty(
'1 - 17% Utilized', 0xE0, 5, 0b001, MajorDeviceClass.AP
)
AP_USAGE_17_33 = ClassProperty(
'17 - 33% Utilized', 0xE0, 5, 0b010, MajorDeviceClass.AP
)
AP_USAGE_33_50 = ClassProperty(
'33 - 50% Utilized', 0xE0, 5, 0b011, MajorDeviceClass.AP
)
AP_USAGE_50_67 = ClassProperty(
'50 - 67% Utilized', 0xE0, 5, 0b100, MajorDeviceClass.AP
)
AP_USAGE_67_83 = ClassProperty(
'67 - 83% Utilized', 0xE0, 5, 0b101, MajorDeviceClass.AP
)
AP_USAGE_83_99 = ClassProperty(
'83 - 99% Utilized', 0xE0, 5, 0b110, MajorDeviceClass.AP
)
AP_USAGE_100 = ClassProperty(
'No Service Available', 0xE0, 5, 0b111, MajorDeviceClass.AP
)
# Multimedia classes
MULTIMEDIA_HEADSET = ClassProperty(
'Headset', 0xFC, 2, 0b000001, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_HANDS_FREE = ClassProperty(
'Hands-free Device', 0xFC, 2, 0b000010, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_MICROPHONE = ClassProperty(
'Microphone', 0xFC, 2, 0b000100, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_LOUDSPEAKER = ClassProperty(
'Loudspeaker', 0xFC, 2, 0b000101, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_HEADPHONES = ClassProperty(
'Headphones', 0xFC, 2, 0b000110, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_PORTABLE_AUDIO = ClassProperty(
'Portable Audio', 0xFC, 2, 0b000111, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_CAR_AUDIO = ClassProperty(
'Car Audio', 0xFC, 2, 0b001000, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_SET_TOP_BOX = ClassProperty(
'Set-top Box', 0xFC, 2, 0b001001, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_HIFI_AUDIO = ClassProperty(
'HiFi Audio Device', 0xFC, 2, 0b001010, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_VCR = ClassProperty(
'VCR', 0xFC, 2, 0b001011, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_VIDEO_CAMERA = ClassProperty(
'Video Camera', 0xFC, 2, 0b001100, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_CAMCODER = ClassProperty(
'Camcoder', 0xFC, 2, 0b001101, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_VIDEO_MONITOR = ClassProperty(
'Video Monitor', 0xFC, 2, 0b001110, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_VIDEO_DISPLAY_AND_LOUDSPEAKER = ClassProperty(
'Video Display and Loudspeaker', 0xFC, 2, 0b001111, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_VIDEO_CONFERENCING = ClassProperty(
'Video Conferencing', 0xFC, 2, 0b010000, MajorDeviceClass.MULTIMEDIA
)
MULTIMEDIA_GAMING_TOY = ClassProperty(
'Gaming / Toy', 0xFC, 2, 0b010010, MajorDeviceClass.MULTIMEDIA
)
# Peripheral classes
PERIPHERAL_UNKNOWN = ClassProperty(
'Unknown', 0xFC, 2, 0b000000, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_KEYBOARD = ClassProperty(
'Keyboard', 0xC0, 6, 0b01, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_POINTER = ClassProperty(
'Pointing Device', 0xC0, 6, 0b10, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_KEYBOARD_POINTER = ClassProperty(
'Combo Keyboard/Pointing Device', 0xC0, 6, 0b11, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_JOYSTICK = ClassProperty(
'Joystick', 0x3C, 2, 0b0001, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_GAMEPAD = ClassProperty(
'Gamepad', 0x3C, 2, 0b0010, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_REMOTE_CONTROL = ClassProperty(
'Remote Control', 0x3C, 2, 0b0011, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_SENSOR = ClassProperty(
'Sensing Device', 0x3C, 2, 0b0100, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_DIGIT_TABLET = ClassProperty(
'Digitizer Tablet', 0x3C, 2, 0b0101, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_CARD_READER = ClassProperty(
'Card Reader', 0x3C, 2, 0b0110, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_DIGITAL_PEN = ClassProperty(
'Card Reader', 0x3C, 2, 0b0111, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_SCANNER = ClassProperty(
'Handheld Scanner', 0x3C, 2, 0b1000, MajorDeviceClass.PERIPHERAL
)
PERIPHERAL_GESTURES = ClassProperty(
'Handheld Gesture Input Device', 0x3C, 2, 0b1001, MajorDeviceClass.PERIPHERAL
)
# Imaging classes
IMAGING_UNKNOWN = ClassProperty(
'Unknown', 0xFC, 2, 0b000000, MajorDeviceClass.IMAGING
)
IMAGING_DISPLAY = ClassProperty(
'Display', 0xF0, 4, 0b0001, MajorDeviceClass.IMAGING
)
IMAGING_CAMERA = ClassProperty('Camera', 0xF0, 4, 0b0010, MajorDeviceClass.IMAGING)
IMAGING_SCANNER = ClassProperty(
'Scanner', 0xF0, 4, 0b0100, MajorDeviceClass.IMAGING
)
IMAGING_PRINTER = ClassProperty(
'Printer', 0xF0, 4, 0b1000, MajorDeviceClass.IMAGING
)
# Wearable classes
WEARABLE_UNKNOWN = ClassProperty(
'Unknown', 0xFC, 2, 0b000000, MajorDeviceClass.WEARABLE
)
WEARABLE_WRISTWATCH = ClassProperty(
'Wristwatch', 0xFC, 2, 0b000001, MajorDeviceClass.WEARABLE
)
WEARABLE_PAGER = ClassProperty(
'Pager', 0xFC, 2, 0b000010, MajorDeviceClass.WEARABLE
)
WEARABLE_JACKET = ClassProperty(
'Jacket', 0xFC, 2, 0b000011, MajorDeviceClass.WEARABLE
)
WEARABLE_HELMET = ClassProperty(
'Helmet', 0xFC, 2, 0b000100, MajorDeviceClass.WEARABLE
)
WEARABLE_GLASSES = ClassProperty(
'Glasses', 0xFC, 2, 0b000101, MajorDeviceClass.WEARABLE
)
# Toy classes
TOY_UNKNOWN = ClassProperty('Unknown', 0xFC, 2, 0b000000, MajorDeviceClass.TOY)
TOY_ROBOT = ClassProperty('Robot', 0xFC, 2, 0b000001, MajorDeviceClass.TOY)
TOY_VEHICLE = ClassProperty('Vehicle', 0xFC, 2, 0b000010, MajorDeviceClass.TOY)
TOY_DOLL = ClassProperty(
'Doll / Action Figure', 0xFC, 2, 0b000011, MajorDeviceClass.TOY
)
TOY_CONTROLLER = ClassProperty(
'Controller', 0xFC, 2, 0b000100, MajorDeviceClass.TOY
)
TOY_GAME = ClassProperty('Game', 0xFC, 2, 0b000101, MajorDeviceClass.TOY)
# Health classes
HEALTH_UNKNOWN = ClassProperty(
'Unknown', 0xFC, 2, 0b000000, MajorDeviceClass.HEALTH
)
HEALTH_BLOOD_PRESSURE = ClassProperty(
'Blood Pressure Monitor', 0xFC, 2, 0b000001, MajorDeviceClass.HEALTH
)
HEALTH_THERMOMETER = ClassProperty(
'Thermometer', 0xFC, 2, 0b000010, MajorDeviceClass.HEALTH
)
HEALTH_SCALE = ClassProperty(
'Weighing Scale', 0xFC, 2, 0b000011, MajorDeviceClass.HEALTH
)
HEALTH_GLUCOSE = ClassProperty(
'Glucose Meter', 0xFC, 2, 0b000100, MajorDeviceClass.HEALTH
)
HEALTH_OXIMETER = ClassProperty(
'Pulse Oximeter', 0xFC, 2, 0b000101, MajorDeviceClass.HEALTH
)
HEALTH_PULSE = ClassProperty(
'Heart Rate/Pulse Monitor', 0xFC, 2, 0b000110, MajorDeviceClass.HEALTH
)
HEALTH_DISPLAY = ClassProperty(
'Health Data Display', 0xFC, 2, 0b000111, MajorDeviceClass.HEALTH
)
HEALTH_STEPS = ClassProperty(
'Step Counter', 0xFC, 2, 0b001000, MajorDeviceClass.HEALTH
)
HEALTH_COMPOSITION = ClassProperty(
'Body Composition Analyzer', 0xFC, 2, 0b001001, MajorDeviceClass.HEALTH
)
HEALTH_PEAK_FLOW = ClassProperty(
'Peak Flow Monitor', 0xFC, 2, 0b001010, MajorDeviceClass.HEALTH
)
HEALTH_MEDICATION = ClassProperty(
'Medication Monitor', 0xFC, 2, 0b001011, MajorDeviceClass.HEALTH
)
HEALTH_KNEE_PROSTHESIS = ClassProperty(
'Knee Prosthesis', 0xFC, 2, 0b001100, MajorDeviceClass.HEALTH
)
HEALTH_ANKLE_PROSTHESIS = ClassProperty(
'Ankle Prosthesis', 0xFC, 2, 0b001101, MajorDeviceClass.HEALTH
)
HEALTH_GENERIC = ClassProperty(
'Generic Health Manager', 0xFC, 2, 0b001110, MajorDeviceClass.HEALTH
)
HEALTH_MOBILITY = ClassProperty(
'Personal Mobility Device', 0xFC, 2, 0b001111, MajorDeviceClass.HEALTH
)

View File

@ -0,0 +1,19 @@
from ._base import BaseBluetoothClass, ClassProperty
class MajorServiceClass(BaseBluetoothClass):
"""
Models Bluetooth major service classes - see
https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Assigned%20Numbers.pdf,
Section 2.8.1
"""
LE_AUDIO = ClassProperty('Low-energy Audio', 1 << 14, 14)
POSITIONING = ClassProperty('Positioning', 1 << 16, 16)
NETWORKING = ClassProperty('Networking', 1 << 17, 17)
RENDERING = ClassProperty('Rendering', 1 << 18, 18)
CAPTURING = ClassProperty('Capturing', 1 << 19, 19)
OBJECT_TRANSFER = ClassProperty('Object Transfer', 1 << 20, 20)
AUDIO = ClassProperty('Audio', 1 << 21, 21)
TELEPHONY = ClassProperty('Telephony', 1 << 22, 22)
INFORMATION = ClassProperty('Information', 1 << 23, 23)

View File

@ -0,0 +1,43 @@
from enum import Enum
class Protocol(Enum):
"""
Models a Bluetooth protocol.
"""
UNKNOWN = 'UNKNOWN'
RFCOMM = 'RFCOMM'
L2CAP = 'L2CAP'
TCP = 'TCP'
UDP = 'UDP'
SDP = 'SDP'
BNEP = 'BNEP'
TCS_BIN = 'TCS-BIN'
TCS_AT = 'TCS-AT'
OBEX = 'OBEX'
IP = 'IP'
FTP = 'FTP'
HTTP = 'HTTP'
WSP = 'WSP'
UPNP = 'UPNP'
HIDP = 'HIDP'
AVCTP = 'AVCTP'
AVDTP = 'AVDTP'
CMTP = 'CMTP'
UDI_C_PLANE = 'UDI_C-Plane'
HardCopyControlChannel = 'HardCopyControlChannel'
HardCopyDataChannel = 'HardCopyDataChannel'
HardCopyNotification = 'HardCopyNotification'
def __str__(self) -> str:
"""
Only returns the value of the enum.
"""
return self.value
def __repr__(self) -> str:
"""
Only returns the value of the enum.
"""
return str(self)

View File

@ -0,0 +1,6 @@
from ._directory import ServiceClass
__all__ = [
"ServiceClass",
]

View File

@ -0,0 +1,177 @@
import re
from enum import Enum
from typing import Dict
from uuid import UUID
import bluetooth_numbers
from bleak.uuids import uuid16_dict, uuid128_dict
from platypush.plugins.bluetooth._types import RawServiceClass
def _service_name_to_enum_name(service_name: str) -> str:
"""
Convert a service name to an enum-key compatible string.
"""
ret = service_name.title()
ret = re.sub(r"\(.+?\)", "", ret)
ret = re.sub(r"\s+", "_", ret)
ret = re.sub(r"[^a-zA-Z0-9_]", "", ret)
ret = re.sub(r"_+", "_", ret)
return ret.upper()
_service_classes: Dict[RawServiceClass, str] = {
0x0: "Unknown",
0x1000: "Service Discovery Server Service Class ID",
0x1001: "Browse Group Descriptor Service Class ID",
0x1101: "Serial Port",
0x1102: "LAN Access Using PPP",
0x1103: "Dialup Networking",
0x1104: "IR MC Sync",
0x1105: "OBEX Object Push",
0x1106: "OBEX File Transfer",
0x1107: "IR MC Sync Command",
0x1108: "Headset",
0x1109: "Cordless Telephony",
0x110A: "Audio Source",
0x110B: "Audio Sink",
0x110C: "A/V Remote Control Target",
0x110D: "Advanced Audio Distribution",
0x110E: "A/V Remote Control",
0x110F: "A/V Remote Control Controller",
0x1110: "Intercom",
0x1111: "Fax",
0x1112: "Headset Audio Gateway",
0x1113: "WAP",
0x1114: "WAP Client",
0x1115: "PANU",
0x1116: "NAP",
0x1117: "GN",
0x1118: "Direct Printing",
0x1119: "Reference Printing",
0x111A: "Basic Imaging Profile",
0x111B: "Imaging Responder",
0x111C: "Imaging Automatic Archive",
0x111D: "Imaging Referenced Objects",
0x111E: "Handsfree",
0x111F: "Handsfree Audio Gateway",
0x1120: "Direct Printing Reference Objects Service",
0x1121: "Reflected UI",
0x1122: "Basic Printing",
0x1123: "Printing Status",
0x1124: "Human Interface Device Service",
0x1125: "Hard Copy Cable Replacement",
0x1126: "HCR Print",
0x1127: "HCR Scan",
0x1128: "Common ISDN Access",
0x112D: "SIM Access",
0x112E: "Phone Book Access PCE",
0x112F: "Phone Book Access PSE",
0x1130: "Phone Book Access",
0x1131: "Headset NS",
0x1132: "Message Access Server",
0x1133: "Message Notification Server",
0x1134: "Message Notification Profile",
0x1135: "GNSS",
0x1136: "GNSS Server",
0x1137: "3D Display",
0x1138: "3D Glasses",
0x1139: "3D Synchronization",
0x113A: "MPS Profile",
0x113B: "MPS SC",
0x113C: "CTN Access Service",
0x113D: "CTN Notification Service",
0x113E: "CTN Profile",
0x1200: "PnP Information",
0x1201: "Generic Networking",
0x1202: "Generic File Transfer",
0x1203: "Generic Audio",
0x1204: "Generic Telephony",
0x1205: "UPNP Service",
0x1206: "UPNP IP Service",
0x1300: "ESDP UPNP IP PAN",
0x1301: "ESDP UPNP IP LAP",
0x1302: "ESDP UPNP L2CAP",
0x1303: "Video Source",
0x1304: "Video Sink",
0x1305: "Video Distribution",
0x1400: "HDP",
0x1401: "HDP Source",
0x1402: "HDP Sink",
}
"""
Directory of known Bluetooth service UUIDs.
See
https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Assigned%20Numbers.pdf,
Section 3.3.
"""
# Update the base services with the GATT service UUIDs defined in ``bluetooth_numbers``. See
# https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Assigned%20Numbers.pdf,
# Section 3.4
_service_classes.update(bluetooth_numbers.service)
# Extend the service classes with the GATT service UUIDs defined in Bleak
_service_classes.update(uuid16_dict) # type: ignore
_service_classes.update({UUID(uuid): name for uuid, name in uuid128_dict.items()})
_service_classes_by_name: Dict[str, RawServiceClass] = {
name: cls for cls, name in _service_classes.items()
}
class _ServiceClassMeta:
"""
Metaclass for :class:`ServiceClass`.
"""
value: RawServiceClass
""" The raw service class value. """
@classmethod
def get(cls, value: RawServiceClass) -> "ServiceClass":
"""
:param value: The raw service class UUID.
:return: The parsed :class:`ServiceClass` instance, or
``ServiceClass.UNKNOWN``.
"""
try:
return ServiceClass(value)
except ValueError:
try:
if isinstance(value, UUID):
return ServiceClass(int(str(value).upper()[4:8], 16))
except ValueError:
pass
return ServiceClass.UNKNOWN # type: ignore
@classmethod
def by_name(cls, name: str) -> "ServiceClass":
"""
:param name: The name of the service class.
:return: The :class:`ServiceClass` instance, or
``ServiceClass.UNKNOWN``.
"""
return (
ServiceClass(_service_classes_by_name.get(name))
or ServiceClass.UNKNOWN # type: ignore
)
def __str__(self) -> str:
return _service_classes.get(
self.value, ServiceClass.UNKNOWN.value # type: ignore
)
def __repr__(self) -> str:
return f"<{self.value}: {str(self)}>"
ServiceClass = Enum( # type: ignore
"ServiceClass",
{_service_name_to_enum_name(name): cls for cls, name in _service_classes.items()},
type=_ServiceClassMeta,
)
""" Enumeration of known Bluetooth services. """

View File

@ -0,0 +1,33 @@
# mypy stub for ServiceClass
from enum import Enum
from platypush.plugins.bluetooth._types import RawServiceClass
class ServiceClass(Enum):
"""
Enumeration of supported Bluetooth service classes.
"""
value: RawServiceClass
""" The raw service class value. """
UNKNOWN = ...
""" A class for unknown services. """
OBEX_OBJECT_PUSH = ...
""" Class for the OBEX Object Push service. """
@classmethod
def get(cls, value: RawServiceClass) -> "ServiceClass":
"""
:param value: The raw service class UUID.
:return: The parsed :class:`ServiceClass` instance, or
``ServiceClass.UNKNOWN``.
"""
@classmethod
def by_name(cls, name: str) -> "ServiceClass":
"""
:param name: The name of the service class.
:return: The :class:`ServiceClass` instance, or
``ServiceClass.UNKNOWN``.
"""

View File

@ -0,0 +1,603 @@
import base64
import os
from queue import Empty, Queue
import threading
import time
from typing import (
Collection,
Dict,
Final,
List,
Optional,
Union,
Type,
)
from typing_extensions import override
from platypush.context import get_bus, get_plugin
from platypush.entities import EntityManager, get_entities_engine
from platypush.entities.bluetooth import BluetoothDevice, BluetoothService
from platypush.message.event.bluetooth import (
BluetoothScanPausedEvent,
BluetoothScanResumedEvent,
)
from platypush.plugins import RunnablePlugin, action
from platypush.plugins.db import DbPlugin
from ._ble import BLEManager
from ._cache import EntityCache
from ._legacy import LegacyManager
from ._types import RawServiceClass
from ._manager import BaseBluetoothManager
class BluetoothPlugin(RunnablePlugin, EntityManager):
"""
Plugin to interact with Bluetooth devices.
This plugin uses `_Bleak_ <https://github.com/hbldh/bleak>`_ to interact
with the Bluetooth stack and `_Theengs_ <https://github.com/theengs/decoder>`_
to map the services exposed by the devices into native entities.
The full list of devices natively supported can be found
`here <https://decoder.theengs.io/devices/devices_by_brand.html>`_.
Note that the support for Bluetooth low-energy devices requires a Bluetooth
adapter compatible with the Bluetooth 5.0 specification or higher.
Requires:
* **bleak** (``pip install bleak``)
* **bluetooth-numbers** (``pip install bluetooth-numbers``)
* **TheengsGateway** (``pip install git+https://github.com/theengs/gateway``)
* **pybluez** (``pip install git+https://github.com/pybluez/pybluez``)
* **pyobex** (``pip install git+https://github.com/BlackLight/PyOBEX``)
Triggers:
* :class:`platypush.message.event.bluetooth.BluetoothConnectionFailedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent`
* :class:`platypush.message.event.bluetooth.BluetoothFileReceivedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothFileSentEvent`
* :class:`platypush.message.event.bluetooth.BluetoothFileTransferStartedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothScanPausedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothScanResumedEvent`
* :class:`platypush.message.event.entities.EntityUpdateEvent`
"""
_default_connect_timeout: Final[int] = 20
""" Default connection timeout (in seconds) """
_default_scan_duration: Final[float] = 10.0
""" Default duration of a discovery session (in seconds) """
def __init__(
self,
interface: Optional[str] = None,
connect_timeout: float = _default_connect_timeout,
service_uuids: Optional[Collection[RawServiceClass]] = None,
scan_paused_on_start: bool = False,
poll_interval: float = _default_scan_duration,
**kwargs,
):
"""
:param interface: Name of the Bluetooth interface to use (e.g. ``hci0``
on Linux). Default: first available interface.
:param connect_timeout: Timeout in seconds for the connection to a
Bluetooth device. Default: 20 seconds.
:param service_uuids: List of service UUIDs to discover.
Default: all.
:param scan_paused_on_start: If ``True``, the plugin will not the
scanning thread until :meth:`.scan_resume` is called (default:
``False``).
"""
kwargs['poll_interval'] = poll_interval
super().__init__(**kwargs)
self._interface: Optional[str] = interface
""" Default Bluetooth interface to use """
self._connect_timeout: float = connect_timeout
""" Connection timeout in seconds """
self._service_uuids: Collection[RawServiceClass] = service_uuids or []
""" UUIDs to discover """
self._scan_lock = threading.RLock()
""" Lock to synchronize scanning access to the Bluetooth device """
self._scan_enabled = threading.Event()
""" Event used to enable/disable scanning """
self._device_queue: Queue[BluetoothDevice] = Queue()
"""
Queue used by the Bluetooth managers to published the discovered
Bluetooth devices.
"""
self._device_cache = EntityCache()
"""
Cache of the devices discovered by the plugin.
"""
self._managers: Dict[Type[BaseBluetoothManager], BaseBluetoothManager] = {}
"""
Bluetooth managers threads, one for BLE devices and one for non-BLE
devices.
"""
self._scan_controller_timer: Optional[threading.Timer] = None
""" Timer used to temporarily pause the discovery process """
if not scan_paused_on_start:
self._scan_enabled.set()
def _refresh_cache(self) -> None:
# Wait for the entities engine to start
get_entities_engine().wait_start()
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
existing_devices = [d.copy() for d in session.query(BluetoothDevice).all()]
for dev in existing_devices:
self._device_cache.add(dev)
def _init_bluetooth_managers(self):
"""
Initializes the Bluetooth managers threads.
"""
manager_args = {
'interface': self._interface,
'poll_interval': self.poll_interval,
'connect_timeout': self._connect_timeout,
'stop_event': self._should_stop,
'scan_lock': self._scan_lock,
'scan_enabled': self._scan_enabled,
'device_queue': self._device_queue,
'service_uuids': list(map(BluetoothService.to_uuid, self._service_uuids)),
'device_cache': self._device_cache,
}
self._managers = {
BLEManager: BLEManager(**manager_args),
LegacyManager: LegacyManager(**manager_args),
}
def _scan_state_set(self, state: bool, duration: Optional[float] = None):
"""
Set the state of the scanning process.
:param state: ``True`` to enable the scanning process, ``False`` to
disable it.
:param duration: The duration of the pause (in seconds) or ``None``.
"""
def timer_callback():
if state:
self.scan_pause()
else:
self.scan_resume()
self._scan_controller_timer = None
with self._scan_lock:
if not state and self._scan_enabled.is_set():
get_bus().post(BluetoothScanPausedEvent(duration=duration))
elif state and not self._scan_enabled.is_set():
get_bus().post(BluetoothScanResumedEvent(duration=duration))
if state:
self._scan_enabled.set()
else:
self._scan_enabled.clear()
if duration and not self._scan_controller_timer:
self._scan_controller_timer = threading.Timer(duration, timer_callback)
self._scan_controller_timer.start()
def _cancel_scan_controller_timer(self):
"""
Cancels a scan controller timer if scheduled.
"""
if self._scan_controller_timer:
self._scan_controller_timer.cancel()
def _manager_by_device(
self,
device: BluetoothDevice,
port: Optional[int] = None,
service_uuid: Optional[Union[str, RawServiceClass]] = None,
) -> BaseBluetoothManager:
"""
:param device: A discovered Bluetooth device.
:param port: The port to connect to.
:param service_uuid: The UUID of the service to connect to.
:return: The manager associated with the device (BLE or legacy).
"""
# No port nor service UUID -> use the BLE manager for direct connection
if not (port or service_uuid):
return self._managers[BLEManager]
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
matching_services = (
[srv for srv in device.services if srv.port == port]
if port
else [srv for srv in device.services if srv.uuid == uuid]
)
assert matching_services, (
f'No service found on the device {device} for port={port}, '
f'service_uuid={service_uuid}'
)
srv = matching_services[0]
return (
self._managers[BLEManager] if srv.is_ble else self._managers[LegacyManager]
)
def _get_device(self, device: str, _fail_if_not_cached=False) -> BluetoothDevice:
"""
Get a device by its address or name, and scan for it if it's not
cached.
"""
dev = self._device_cache.get(device)
if dev:
return dev
assert not _fail_if_not_cached, f'Device {device} not found'
self.logger.info('Scanning for unknown device %s', device)
self.scan()
return self._get_device(device, _fail_if_not_cached=True)
@action
def connect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[Union[RawServiceClass, str]] = None,
interface: Optional[str] = None,
timeout: Optional[float] = None,
):
"""
Pair and connect to a device by address or name.
:param device: The device address or name.
:param port: The port to connect to. Either ``port`` or
``service_uuid`` is required for non-BLE devices.
:param service_uuid: The UUID of the service to connect to. Either
``port`` or ``service_uuid`` is required for non-BLE devices.
:param interface: The Bluetooth interface to use (it overrides the
default ``interface``).
:param timeout: The connection timeout in seconds (it overrides the
default ``connect_timeout``).
"""
dev = self._get_device(device)
manager = self._manager_by_device(dev, port=port, service_uuid=service_uuid)
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
manager.connect(
dev.address,
port=port,
service_uuid=uuid,
interface=interface,
timeout=timeout,
)
@action
def disconnect(
self,
device: str,
port: Optional[int] = None,
service_uuid: Optional[RawServiceClass] = None,
):
"""
Close an active connection to a device.
Note that this method can only close connections that have been
initiated by the application. It can't close connections owned by
other applications or agents.
:param device: The device address or name.
:param port: If connected to a non-BLE device, the optional port to
disconnect.
:param service_uuid: The optional UUID of the service to disconnect
from, for non-BLE devices.
"""
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None
err = None
success = False
for manager in self._managers.values():
try:
manager.disconnect(dev.address, port=port, service_uuid=uuid)
success = True
except Exception as e:
err = e
assert success, f'Could not disconnect from {device}: {err}'
@action
def scan_pause(self, duration: Optional[float] = None):
"""
Pause the scanning thread.
:param duration: For how long the scanning thread should be paused
(default: null = indefinitely).
"""
self._scan_state_set(False, duration)
@action
def scan_resume(self, duration: Optional[float] = None):
"""
Resume the scanning thread, if inactive.
:param duration: For how long the scanning thread should be running
(default: null = indefinitely).
"""
self._scan_state_set(True, duration)
@action
def scan(
self,
duration: Optional[float] = None,
devices: Optional[Collection[str]] = None,
service_uuids: Optional[Collection[RawServiceClass]] = None,
) -> List[BluetoothDevice]:
"""
Scan for Bluetooth devices nearby and return the results as a list of
entities.
:param duration: Scan duration in seconds (default: same as the plugin's
`poll_interval` configuration parameter)
:param devices: List of device addresses or names to scan for.
:param service_uuids: List of service UUIDs to discover. Default: all.
"""
scanned_device_addresses = set()
duration = duration or self.poll_interval or self._default_scan_duration
uuids = {BluetoothService.to_uuid(uuid) for uuid in (service_uuids or [])}
for manager in self._managers.values():
scanned_device_addresses.update(
[
device.address
for device in manager.scan(duration=duration // len(self._managers))
if (not uuids or any(srv.uuid in uuids for srv in device.services))
and (
not devices
or device.address in devices
or device.name in devices
)
]
)
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
return [
d.copy()
for d in session.query(BluetoothDevice).all()
if d.address in scanned_device_addresses
]
@action
def read(
self,
device: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
) -> str:
"""
Read a message from a device.
:param device: Name or address of the device to read from.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None).
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
:return: The base64-encoded response received from the device.
"""
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid)
manager = self._manager_by_device(dev, service_uuid=uuid)
data = manager.read(
dev.address, uuid, interface=interface, connect_timeout=connect_timeout
)
return base64.b64encode(data).decode()
@action
def write(
self,
device: str,
data: str,
service_uuid: RawServiceClass,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
"""
Writes data to a device
:param device: Name or address of the device to read from.
:param data: Data to be written, as a base64-encoded string.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None)
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
"""
binary_data = base64.b64decode(data.encode())
dev = self._get_device(device)
uuid = BluetoothService.to_uuid(service_uuid)
manager = self._manager_by_device(dev, service_uuid=uuid)
manager.write(
dev.address,
binary_data,
service_uuid=uuid,
interface=interface,
connect_timeout=connect_timeout,
)
@action
def send_file(
self,
file: str,
device: str,
data: Optional[Union[str, bytes, bytearray]] = None,
binary: bool = False,
):
"""
Send a file to a device that exposes an OBEX Object Push service.
:param file: Path of the file to be sent. If ``data`` is specified
then ``file`` should include the proposed file on the
receiving host.
:param data: Alternatively to a file on disk you can send raw (string
or binary) content.
:param device: Device address or name.
:param binary: Set to true if data is a base64-encoded binary string.
"""
from ._file import FileSender
if not data:
file = os.path.abspath(os.path.expanduser(file))
with open(file, 'rb') as f:
binary_data = f.read()
else:
if binary:
binary_data = base64.b64decode(
data.encode() if isinstance(data, str) else data
)
sender = FileSender(self._managers[LegacyManager]) # type: ignore
sender.send_file(file, device, binary_data)
@override
@action
def status(
self,
*_,
duration: Optional[float] = None,
devices: Optional[Collection[str]] = None,
service_uuids: Optional[Collection[RawServiceClass]] = None,
**__,
) -> List[BluetoothDevice]:
"""
Retrieve the status of all the devices, or the matching
devices/services.
If scanning is currently disabled, it will enable it and perform a
scan.
The differences between this method and :meth:`.scan` are:
1. :meth:`.status` will return the status of all the devices known
to the application, while :meth:`.scan` will return the status
only of the devices discovered in the provided time window.
2. :meth:`.status` will not initiate a new scan if scanning is
already enabled (it will only returned the status of the known
devices), while :meth:`.scan` will initiate a new scan.
:param duration: Scan duration in seconds, if scanning is disabled
(default: same as the plugin's `poll_interval` configuration
parameter)
:param devices: List of device addresses or names to filter for.
Default: all.
:param service_uuids: List of service UUIDs to filter for. Default:
all.
"""
if not self._scan_enabled.is_set():
self.scan(
duration=duration,
devices=devices,
service_uuids=service_uuids,
)
with get_plugin(DbPlugin).get_session(
autoflush=False, autocommit=False, expire_on_commit=False
) as session:
known_devices = [
d.copy()
for d in session.query(BluetoothDevice).all()
if (not devices or d.address in devices or d.name in devices)
and (
not service_uuids
or any(str(srv.uuid) in service_uuids for srv in d.services)
)
]
# Send entity update events to keep any asynchronous clients in sync
get_entities_engine().notify(*known_devices)
return known_devices
@override
def transform_entities(
self, entities: Collection[BluetoothDevice]
) -> Collection[BluetoothDevice]:
return super().transform_entities(entities)
@override
def main(self):
self._refresh_cache()
self._init_bluetooth_managers()
for manager in self._managers.values():
manager.start()
try:
while not self.should_stop():
try:
device = self._device_queue.get(timeout=1)
except Empty:
continue
device = self._device_cache.add(device)
self.publish_entities([device], callback=self._device_cache.add)
finally:
self.stop()
@override
def stop(self):
"""
Upon stop request, it stops any pending scans and closes all active
connections.
"""
super().stop()
# Stop any pending scan controller timers
self._cancel_scan_controller_timer()
# Set the stop events on the manager threads
for manager in self._managers.values():
if manager and manager.is_alive():
self.logger.info('Waiting for %s to stop', manager.name)
try:
manager.stop()
except Exception as e:
self.logger.exception(
'Error while stopping %s: %s', manager.name, e
)
# Wait for the manager threads to stop
stop_timeout = 5
wait_start = time.time()
for manager in self._managers.values():
if (
manager
and manager.ident != threading.current_thread().ident
and manager.is_alive()
):
manager.join(timeout=max(0, stop_timeout - (time.time() - wait_start)))
if manager and manager.is_alive():
self.logger.warning(
'Timeout while waiting for %s to stop', manager.name
)
__all__ = ["BluetoothPlugin"]
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,8 @@
from typing import Union
from uuid import UUID
RawServiceClass = Union[UUID, int]
"""
Raw type for service classes received by pybluez.
Can be either a 16-bit integer or a UUID.
"""

View File

@ -1,524 +0,0 @@
import base64
from asyncio import Event, Lock, ensure_future
from contextlib import asynccontextmanager
from threading import RLock, Timer
from time import time
from typing import (
Any,
AsyncGenerator,
Collection,
Final,
List,
Optional,
Dict,
Type,
Union,
)
from uuid import UUID
from bleak import BleakClient, BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from typing_extensions import override
from platypush.context import get_bus, get_or_create_event_loop
from platypush.entities import Entity, EntityManager
from platypush.entities.bluetooth import BluetoothDevice
from platypush.message.event.bluetooth import (
BluetoothDeviceBlockedEvent,
BluetoothDeviceConnectedEvent,
BluetoothDeviceDisconnectedEvent,
BluetoothDeviceFoundEvent,
BluetoothDeviceLostEvent,
BluetoothDeviceNewDataEvent,
BluetoothDevicePairedEvent,
BluetoothDeviceSignalUpdateEvent,
BluetoothDeviceTrustedEvent,
BluetoothDeviceUnblockedEvent,
BluetoothDeviceUnpairedEvent,
BluetoothDeviceUntrustedEvent,
BluetoothDeviceEvent,
BluetoothScanPausedEvent,
BluetoothScanResumedEvent,
)
from platypush.plugins import AsyncRunnablePlugin, action
from ._mappers import device_to_entity, parse_device_args
UUIDType = Union[str, UUID]
class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
"""
Plugin to interact with BLE (Bluetooth Low-Energy) devices.
This plugin uses `_Bleak_ <https://github.com/hbldh/bleak>`_ to interact
with the Bluetooth stack and `_Theengs_ <https://github.com/theengs/decoder>`_
to map the services exposed by the devices into native entities.
The full list of devices natively supported can be found
`here <https://decoder.theengs.io/devices/devices_by_brand.html>`_.
Note that the support for Bluetooth low-energy devices requires a Bluetooth
adapter compatible with the Bluetooth 5.0 specification or higher.
Requires:
* **bleak** (``pip install bleak``)
* **bluetooth-numbers** (``pip install bluetooth-numbers``)
* **TheengsGateway** (``pip install git+https://github.com/BlackLight/TheengsGateway``)
Triggers:
* :class:`platypush.message.event.bluetooth.BluetoothDeviceBlockedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceNewDataEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDevicePairedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceTrustedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceUnblockedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceUnpairedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothDeviceUntrustedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothScanPausedEvent`
* :class:`platypush.message.event.bluetooth.BluetoothScanResumedEvent`
"""
_default_connect_timeout: Final[int] = 5
""" Default connection timeout (in seconds) """
_rssi_update_interval: Final[int] = 30
"""
How long we should wait before triggering an update event upon a new
RSSI update, in seconds.
"""
def __init__(
self,
interface: Optional[str] = None,
connect_timeout: float = _default_connect_timeout,
device_names: Optional[Dict[str, str]] = None,
uuids: Optional[Collection[UUIDType]] = None,
scan_paused_on_start: bool = False,
**kwargs,
):
"""
:param interface: Name of the Bluetooth interface to use (e.g. ``hci0``
on Linux). Default: first available interface.
:param connect_timeout: Timeout in seconds for the connection to a
Bluetooth device. Default: 5 seconds.
:param uuids: List of service/characteristic UUIDs to discover.
Default: all.
:param device_names: Bluetooth address -> device name mapping. If not
specified, the device's advertised name will be used, or its
Bluetooth address. Example:
.. code-block:: json
{
"00:11:22:33:44:55": "Switchbot",
"00:11:22:33:44:56": "Headphones",
"00:11:22:33:44:57": "Button"
}
:param scan_paused_on_start: If ``True``, the plugin will not the
scanning thread until :meth:`.scan_resume` is called (default:
``False``).
"""
super().__init__(**kwargs)
self._interface: Optional[str] = interface
self._connect_timeout: float = connect_timeout
self._uuids: Collection[Union[str, UUID]] = uuids or []
self._scan_lock = RLock()
self._scan_enabled = Event()
self._scan_controller_timer: Optional[Timer] = None
self._connections: Dict[str, BleakClient] = {}
self._connection_locks: Dict[str, Lock] = {}
self._devices: Dict[str, BLEDevice] = {}
self._entities: Dict[str, BluetoothDevice] = {}
self._device_last_updated_at: Dict[str, float] = {}
self._device_name_by_addr = device_names or {}
self._device_addr_by_name = {
name: addr for addr, name in self._device_name_by_addr.items()
}
if not scan_paused_on_start:
self._scan_enabled.set()
async def _get_device(self, device: str) -> BLEDevice:
"""
Utility method to get a device by name or address.
"""
addr = (
self._device_addr_by_name[device]
if device in self._device_addr_by_name
else device
)
if addr not in self._devices:
self.logger.info('Scanning for unknown device "%s"', device)
await self._scan()
dev = self._devices.get(addr)
assert dev is not None, f'Unknown device: "{device}"'
return dev
def _post_event(
self, event_type: Type[BluetoothDeviceEvent], device: BLEDevice, **kwargs
):
get_bus().post(
event_type(address=device.address, **parse_device_args(device), **kwargs)
)
def _on_device_event(self, device: BLEDevice, data: AdvertisementData):
"""
Device advertisement packet callback handler.
1. It generates the relevant
:class:`platypush.message.event.bluetooth.BluetoothDeviceEvent` if the
state of the device has changed.
2. It builds the relevant
:class:`platypush.entity.bluetooth.BluetoothDevice` entity object
populated with children entities that contain the supported
properties.
:param device: The Bluetooth device.
:param data: The advertisement data.
"""
event_types: List[Type[BluetoothDeviceEvent]] = []
entity = device_to_entity(device, data)
existing_entity = self._entities.get(device.address)
existing_device = self._devices.get(device.address)
if existing_entity and existing_device:
if existing_entity.paired != entity.paired:
event_types.append(
BluetoothDevicePairedEvent
if entity.paired
else BluetoothDeviceUnpairedEvent
)
if existing_entity.connected != entity.connected:
event_types.append(
BluetoothDeviceConnectedEvent
if entity.connected
else BluetoothDeviceDisconnectedEvent
)
if existing_entity.blocked != entity.blocked:
event_types.append(
BluetoothDeviceBlockedEvent
if entity.blocked
else BluetoothDeviceUnblockedEvent
)
if existing_entity.trusted != entity.trusted:
event_types.append(
BluetoothDeviceTrustedEvent
if entity.trusted
else BluetoothDeviceUntrustedEvent
)
if (
time() - self._device_last_updated_at.get(device.address, 0)
) >= self._rssi_update_interval and (
existing_entity.rssi != device.rssi
or existing_entity.tx_power != entity.tx_power
):
event_types.append(BluetoothDeviceSignalUpdateEvent)
if (
existing_device.metadata.get('manufacturer_data', {})
!= device.metadata.get('manufacturer_data', {})
) or (
existing_device.details.get('props', {}).get('ServiceData', {})
!= device.details.get('props', {}).get('ServiceData', {})
):
event_types.append(BluetoothDeviceNewDataEvent)
else:
event_types.append(BluetoothDeviceFoundEvent)
self._devices[device.address] = device
if device.name:
self._device_name_by_addr[device.address] = device.name
self._device_addr_by_name[device.name] = device.address
if event_types:
for event_type in event_types:
self._post_event(event_type, device)
self._device_last_updated_at[device.address] = time()
for child in entity.children:
child.parent = entity
self.publish_entities([entity])
def _has_changed(self, entity: BluetoothDevice) -> bool:
existing_entity = self._entities.get(entity.id or entity.external_id)
# If the entity didn't exist before, it's a new device.
if not existing_entity:
return True
entity_dict = entity.to_json()
existing_entity_dict = entity.to_json()
# Check if any of the root attributes changed, excluding those that are
# managed by the entities engine).
return any(
attr
for attr, value in entity_dict.items()
if value != existing_entity_dict.get(attr)
and attr not in {'id', 'external_id', 'plugin', 'updated_at'}
)
@asynccontextmanager
async def _connect(
self,
device: str,
interface: Optional[str] = None,
timeout: Optional[float] = None,
) -> AsyncGenerator[BleakClient, None]:
dev = await self._get_device(device)
async with self._connection_locks.get(dev.address, Lock()) as lock:
self._connection_locks[dev.address] = lock or Lock()
async with BleakClient(
dev.address,
adapter=interface or self._interface,
timeout=timeout or self._connect_timeout,
) as client:
self._connections[dev.address] = client
yield client
self._connections.pop(dev.address, None)
async def _read(
self,
device: str,
service_uuid: UUIDType,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
) -> bytearray:
async with self._connect(device, interface, connect_timeout) as client:
data = await client.read_gatt_char(service_uuid)
return data
async def _write(
self,
device: str,
data: bytes,
service_uuid: UUIDType,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
async with self._connect(device, interface, connect_timeout) as client:
await client.write_gatt_char(service_uuid, data)
async def _scan(
self,
duration: Optional[float] = None,
uuids: Optional[Collection[UUIDType]] = None,
) -> Collection[Entity]:
with self._scan_lock:
timeout = duration or self.poll_interval or 5
devices = await BleakScanner.discover(
adapter=self._interface,
timeout=timeout,
service_uuids=list(map(str, uuids or self._uuids or [])),
detection_callback=self._on_device_event,
)
self._devices.update({dev.address: dev for dev in devices})
addresses = {dev.address.lower() for dev in devices}
return [
dev
for addr, dev in self._entities.items()
if isinstance(dev, BluetoothDevice)
and addr.lower() in addresses
and dev.reachable
]
async def _scan_state_set(self, state: bool, duration: Optional[float] = None):
def timer_callback():
if state:
self.scan_pause()
else:
self.scan_resume()
self._scan_controller_timer = None
with self._scan_lock:
if not state and self._scan_enabled.is_set():
get_bus().post(BluetoothScanPausedEvent(duration=duration))
elif state and not self._scan_enabled.is_set():
get_bus().post(BluetoothScanResumedEvent(duration=duration))
if state:
self._scan_enabled.set()
else:
self._scan_enabled.clear()
if duration and not self._scan_controller_timer:
self._scan_controller_timer = Timer(duration, timer_callback)
self._scan_controller_timer.start()
@action
def scan_pause(self, duration: Optional[float] = None):
"""
Pause the scanning thread.
:param duration: For how long the scanning thread should be paused
(default: null = indefinitely).
"""
if self._loop:
ensure_future(self._scan_state_set(False, duration), loop=self._loop)
@action
def scan_resume(self, duration: Optional[float] = None):
"""
Resume the scanning thread, if inactive.
:param duration: For how long the scanning thread should be running
(default: null = indefinitely).
"""
if self._loop:
ensure_future(self._scan_state_set(True, duration), loop=self._loop)
@action
def scan(
self,
duration: Optional[float] = None,
uuids: Optional[Collection[UUIDType]] = None,
):
"""
Scan for Bluetooth devices nearby and return the results as a list of
entities.
:param duration: Scan duration in seconds (default: same as the plugin's
`poll_interval` configuration parameter)
:param uuids: List of characteristic UUIDs to discover. Default: all.
"""
loop = get_or_create_event_loop()
return loop.run_until_complete(self._scan(duration, uuids))
@action
def read(
self,
device: str,
service_uuid: UUIDType,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
) -> str:
"""
Read a message from a device.
:param device: Name or address of the device to read from.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None).
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
:return: The base64-encoded response received from the device.
"""
loop = get_or_create_event_loop()
data = loop.run_until_complete(
self._read(device, service_uuid, interface, connect_timeout)
)
return base64.b64encode(data).decode()
@action
def write(
self,
device: str,
data: Union[str, bytes],
service_uuid: UUIDType,
interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
):
"""
Writes data to a device
:param device: Name or address of the device to read from.
:param data: Data to be written, either as bytes or as a base64-encoded string.
:param service_uuid: Service UUID.
:param interface: Bluetooth adapter name to use (default configured if None)
:param connect_timeout: Connection timeout in seconds (default: same as the
configured `connect_timeout`).
"""
loop = get_or_create_event_loop()
if isinstance(data, str):
data = base64.b64decode(data.encode())
loop.run_until_complete(
self._write(device, data, service_uuid, interface, connect_timeout)
)
@override
@action
def status(self, *_, **__) -> Collection[Entity]:
"""
Alias for :meth:`.scan`.
"""
return self.scan().output
@override
def publish_entities(
self, entities: Optional[Collection[Any]]
) -> Collection[Entity]:
self._entities.update({entity.id: entity for entity in (entities or [])})
return super().publish_entities(entities)
@override
def transform_entities(
self, entities: Collection[Union[BLEDevice, BluetoothDevice]]
) -> Collection[BluetoothDevice]:
return [
BluetoothDevice(
id=dev.address,
**parse_device_args(dev),
)
if isinstance(dev, BLEDevice)
else dev
for dev in entities
]
@override
async def listen(self):
device_addresses = set()
while True:
await self._scan_enabled.wait()
entities = await self._scan(uuids=self._uuids)
new_device_addresses = {e.external_id for e in entities}
missing_device_addresses = device_addresses - new_device_addresses
missing_devices = [
dev
for addr, dev in self._devices.items()
if addr in missing_device_addresses
]
for dev in missing_devices:
self._post_event(BluetoothDeviceLostEvent, dev)
self._devices.pop(dev.address, None)
self._entities.pop(dev.address, None)
device_addresses = new_device_addresses
@override
def stop(self):
if self._scan_controller_timer:
self._scan_controller_timer.cancel()
super().stop()
# vim:sw=4:ts=4:et:

View File

@ -1,22 +0,0 @@
manifest:
events:
platypush.message.event.bluetooth.BluetoothDeviceBlockedEvent:
platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent:
platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent:
platypush.message.event.bluetooth.BluetoothDeviceFoundEvent:
platypush.message.event.bluetooth.BluetoothDeviceLostEvent:
platypush.message.event.bluetooth.BluetoothDeviceNewDataEvent:
platypush.message.event.bluetooth.BluetoothDevicePairedEvent:
platypush.message.event.bluetooth.BluetoothDeviceTrustedEvent:
platypush.message.event.bluetooth.BluetoothDeviceUnblockedEvent:
platypush.message.event.bluetooth.BluetoothDeviceUnpairedEvent:
platypush.message.event.bluetooth.BluetoothDeviceUntrustedEvent:
platypush.message.event.bluetooth.BluetoothScanPausedEvent:
platypush.message.event.bluetooth.BluetoothScanResumedEvent:
install:
pip:
- bleak
- bluetooth-numbers
- git+https://github.com/BlackLight/TheengsGateway
package: platypush.plugins.bluetooth.ble
type: plugin

View File

@ -1,8 +1,23 @@
manifest:
events: {}
events:
platypush.message.event.bluetooth.BluetoothConnectionFailedEvent:
platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent:
platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent:
platypush.message.event.bluetooth.BluetoothDeviceFoundEvent:
platypush.message.event.bluetooth.BluetoothDeviceLostEvent:
platypush.message.event.bluetooth.BluetoothFileReceivedEvent:
platypush.message.event.bluetooth.BluetoothFileSentEvent:
platypush.message.event.bluetooth.BluetoothFileTransferCancelledEvent:
platypush.message.event.bluetooth.BluetoothFileTransferStartedEvent:
platypush.message.event.bluetooth.BluetoothScanPausedEvent:
platypush.message.event.bluetooth.BluetoothScanResumedEvent:
platypush.message.event.entities.EntityUpdateEvent:
install:
pip:
- pybluez
- pyobex
- bleak
- bluetooth-numbers
- git+https://github.com/pybluez/pybluez
- git+https://github.com/theengs/gateway
- git+https://github.com/BlackLight/PyOBEX
package: platypush.plugins.bluetooth
type: plugin

View File

@ -0,0 +1,17 @@
from ._model import (
MajorDeviceClass,
MajorServiceClass,
MinorDeviceClass,
Protocol,
ServiceClass,
)
from ._types import RawServiceClass
__all__ = [
"MajorDeviceClass",
"MajorServiceClass",
"MinorDeviceClass",
"Protocol",
"RawServiceClass",
"ServiceClass",
]

View File

@ -177,8 +177,9 @@ setup(
'bluetooth': [
'bleak',
'bluetooth-numbers',
'pybluez',
'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master',
'pybluez @ https://github.com/pybluez/pybluez/tarball/master',
'PyOBEX @ https://github.com/BlackLight/PyOBEX/tarball/master',
'TheengsGateway @ https://github.com/theengs/gateway/tarball/development',
],
# Support for TP-Link devices
'tplink': ['pyHS100'],