Compare commits
22 Commits
2dfb389630
...
2411b961e8
Author | SHA1 | Date |
---|---|---|
Fabio Manganiello | 2411b961e8 | |
Fabio Manganiello | 4bc61133c5 | |
Fabio Manganiello | 4a8da80c7c | |
Fabio Manganiello | 31552963c4 | |
Fabio Manganiello | f45e47363d | |
Fabio Manganiello | 8ccf3e804d | |
Fabio Manganiello | 60da930e4b | |
Fabio Manganiello | 3fcc9957d1 | |
Fabio Manganiello | ceb7a2f098 | |
Fabio Manganiello | 73dc2463f1 | |
Fabio Manganiello | 7e92d5f244 | |
Fabio Manganiello | 9f9ee575f1 | |
Fabio Manganiello | c4efec6832 | |
Fabio Manganiello | 1781a19a79 | |
Fabio Manganiello | b9efa9fa30 | |
Fabio Manganiello | 72c55c03f2 | |
Fabio Manganiello | a688e7102e | |
Fabio Manganiello | ead4513915 | |
Fabio Manganiello | 94c4e52154 | |
Fabio Manganiello | 7be55e446f | |
Fabio Manganiello | 15fadb93bb | |
Fabio Manganiello | 70d1bb893c |
|
@ -249,6 +249,7 @@ autodoc_mock_imports = [
|
||||||
'pyaudio',
|
'pyaudio',
|
||||||
'avs',
|
'avs',
|
||||||
'PyOBEX',
|
'PyOBEX',
|
||||||
|
'PyOBEX.client',
|
||||||
'todoist',
|
'todoist',
|
||||||
'trello',
|
'trello',
|
||||||
'telegram',
|
'telegram',
|
||||||
|
|
|
@ -8,7 +8,7 @@ from typing import Optional, Any
|
||||||
|
|
||||||
from ..bus import Bus
|
from ..bus import Bus
|
||||||
from ..config import Config
|
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')
|
logger = logging.getLogger('platypush:context')
|
||||||
|
|
||||||
|
@ -108,32 +108,50 @@ def get_backend(name):
|
||||||
return _ctx.backends.get(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
|
Registers a plugin instance by name if not registered already, or returns
|
||||||
the registered plugin instance.
|
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:
|
from ..plugins import Plugin
|
||||||
plugins_init_locks[plugin_name] = RLock()
|
|
||||||
|
|
||||||
if plugin_name in _ctx.plugins and not reload:
|
if isinstance(plugin, str):
|
||||||
return _ctx.plugins[plugin_name]
|
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:
|
if name not in plugins_init_locks:
|
||||||
plugin = importlib.import_module('platypush.plugins.' + plugin_name)
|
plugins_init_locks[name] = RLock()
|
||||||
except ImportError as e:
|
|
||||||
logger.warning('No such plugin: %s', plugin_name)
|
if name in _ctx.plugins and not reload:
|
||||||
raise RuntimeError(e) from e
|
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
|
# e.g. plugins.music.mpd main class: MusicMpdPlugin
|
||||||
cls_name = ''
|
cls_name = ''
|
||||||
for token in plugin_name.split('.'):
|
for token in name.split('.'):
|
||||||
cls_name += token.title()
|
cls_name += token.title()
|
||||||
cls_name += 'Plugin'
|
cls_name += 'Plugin'
|
||||||
|
|
||||||
plugin_conf = (
|
plugin_conf = Config.get_plugins()[name] if name in Config.get_plugins() else {}
|
||||||
Config.get_plugins()[plugin_name] if plugin_name in Config.get_plugins() else {}
|
|
||||||
)
|
|
||||||
|
|
||||||
if 'disabled' in plugin_conf:
|
if 'disabled' in plugin_conf:
|
||||||
if plugin_conf['disabled'] is True:
|
if plugin_conf['disabled'] is True:
|
||||||
|
@ -148,15 +166,15 @@ def get_plugin(plugin_name, reload=False):
|
||||||
try:
|
try:
|
||||||
plugin_class = getattr(plugin, cls_name)
|
plugin_class = getattr(plugin, cls_name)
|
||||||
except AttributeError as e:
|
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
|
raise RuntimeError(e) from e
|
||||||
|
|
||||||
with plugins_init_locks[plugin_name]:
|
with plugins_init_locks[name]:
|
||||||
if _ctx.plugins.get(plugin_name) and not reload:
|
if _ctx.plugins.get(name) and not reload:
|
||||||
return _ctx.plugins[plugin_name]
|
return _ctx.plugins[name]
|
||||||
_ctx.plugins[plugin_name] = plugin_class(**plugin_conf)
|
_ctx.plugins[name] = plugin_class(**plugin_conf)
|
||||||
|
|
||||||
return _ctx.plugins[plugin_name]
|
return _ctx.plugins[name]
|
||||||
|
|
||||||
|
|
||||||
def get_bus() -> Bus:
|
def get_bus() -> Bus:
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from threading import Event
|
||||||
from typing import Collection, Optional
|
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 ._engine import EntitiesEngine
|
||||||
from ._managers import (
|
from ._managers import (
|
||||||
EntityManager,
|
EntityManager,
|
||||||
|
@ -31,7 +38,26 @@ def init_entities_engine() -> EntitiesEngine:
|
||||||
return _engine
|
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.
|
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')
|
logger.debug('No entities engine registered')
|
||||||
return
|
return
|
||||||
|
|
||||||
_engine.post(*entities)
|
_engine.post(*entities, callback=callback)
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
@ -55,6 +81,7 @@ __all__ = (
|
||||||
'EntitiesEngine',
|
'EntitiesEngine',
|
||||||
'Entity',
|
'Entity',
|
||||||
'EntityManager',
|
'EntityManager',
|
||||||
|
'EntitySavedCallback',
|
||||||
'EnumSwitchEntityManager',
|
'EnumSwitchEntityManager',
|
||||||
'LightEntityManager',
|
'LightEntityManager',
|
||||||
'SensorEntityManager',
|
'SensorEntityManager',
|
||||||
|
|
|
@ -3,10 +3,10 @@ import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import types
|
import types
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dateutil.tz import tzutc
|
|
||||||
from typing import Mapping, Type, Tuple, Any
|
|
||||||
|
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
from typing import Callable, Dict, Final, Set, Type, Tuple, Any
|
||||||
|
|
||||||
|
from dateutil.tz import tzutc
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
|
@ -24,7 +24,16 @@ from sqlalchemy.orm import ColumnProperty, Mapped, backref, relationship
|
||||||
from platypush.common.db import Base
|
from platypush.common.db import Base
|
||||||
from platypush.message import JSONAble
|
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:
|
if 'entity' not in Base.metadata:
|
||||||
|
@ -70,7 +79,7 @@ if 'entity' not in Base.metadata:
|
||||||
'Entity',
|
'Entity',
|
||||||
remote_side=[id],
|
remote_side=[id],
|
||||||
uselist=False,
|
uselist=False,
|
||||||
lazy=True,
|
lazy='joined',
|
||||||
post_update=True,
|
post_update=True,
|
||||||
backref=backref(
|
backref=backref(
|
||||||
'children',
|
'children',
|
||||||
|
@ -94,9 +103,9 @@ if 'entity' not in Base.metadata:
|
||||||
'polymorphic_on': type,
|
'polymorphic_on': type,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod # type: ignore
|
||||||
@property
|
@property
|
||||||
def columns(cls) -> Tuple[ColumnProperty]:
|
def columns(cls) -> Tuple[ColumnProperty, ...]:
|
||||||
inspector = schema_inspect(cls)
|
inspector = schema_inspect(cls)
|
||||||
return tuple(inspector.mapper.column_attrs)
|
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.
|
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:
|
def _serialize_value(self, col: ColumnProperty) -> Any:
|
||||||
val = getattr(self, col.key)
|
val = getattr(self, col.key)
|
||||||
|
@ -115,16 +135,31 @@ if 'entity' not in Base.metadata:
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
def to_json(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {col.key: self._serialize_value(col) for col in self.columns}
|
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):
|
def __repr__(self):
|
||||||
|
"""
|
||||||
|
Same as :meth:`.__str__`.
|
||||||
|
"""
|
||||||
return str(self)
|
return str(self)
|
||||||
|
|
||||||
def __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):
|
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]
|
matching_columns = [c for c in self.columns if c.expression.name == key]
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -153,6 +188,12 @@ if 'entity' not in Base.metadata:
|
||||||
# standard multiple inheritance with an SQLAlchemy ORM class)
|
# standard multiple inheritance with an SQLAlchemy ORM class)
|
||||||
Entity.__bases__ = Entity.__bases__ + (JSONAble,)
|
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():
|
def _discover_entity_types():
|
||||||
from platypush.context import get_plugin
|
from platypush.context import get_plugin
|
||||||
|
@ -171,8 +212,15 @@ def _discover_entity_types():
|
||||||
module = types.ModuleType(mod_loader.name)
|
module = types.ModuleType(mod_loader.name)
|
||||||
mod_loader.loader.exec_module(module)
|
mod_loader.loader.exec_module(module)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f'Could not import module {modname}')
|
if (
|
||||||
logger.exception(e)
|
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
|
continue
|
||||||
|
|
||||||
for _, obj in inspect.getmembers(module):
|
for _, obj in inspect.getmembers(module):
|
||||||
|
@ -180,11 +228,17 @@ def _discover_entity_types():
|
||||||
entities_registry[obj] = {}
|
entities_registry[obj] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_entities_registry():
|
def get_entities_registry() -> EntityRegistryType:
|
||||||
|
"""
|
||||||
|
:returns: A copy of the entities registry.
|
||||||
|
"""
|
||||||
return entities_registry.copy()
|
return entities_registry.copy()
|
||||||
|
|
||||||
|
|
||||||
def init_entities_db():
|
def init_entities_db():
|
||||||
|
"""
|
||||||
|
Initializes the entities database.
|
||||||
|
"""
|
||||||
from platypush.context import get_plugin
|
from platypush.context import get_plugin
|
||||||
|
|
||||||
_discover_entity_types()
|
_discover_entity_types()
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from threading import Thread, Event
|
from threading import Thread, Event
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
from platypush.context import get_bus
|
from platypush.context import get_bus
|
||||||
from platypush.entities import Entity
|
from platypush.entities import Entity
|
||||||
from platypush.message.event.entities import EntityUpdateEvent
|
from platypush.message.event.entities import EntityUpdateEvent
|
||||||
from platypush.utils import set_thread_name
|
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.queue import EntitiesQueue
|
||||||
from platypush.entities._engine.repo import EntitiesRepository
|
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__
|
obj_name = self.__class__.__name__
|
||||||
super().__init__(name=obj_name)
|
super().__init__(name=obj_name)
|
||||||
|
|
||||||
self.logger = getLogger(name=obj_name)
|
self.logger = getLogger(name=obj_name)
|
||||||
self._should_stop = Event()
|
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)
|
self._queue = EntitiesQueue(stop_event=self._should_stop)
|
||||||
|
""" Queue where all entity upsert requests are received."""
|
||||||
self._repo = EntitiesRepository()
|
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)
|
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
|
@property
|
||||||
def should_stop(self) -> bool:
|
def should_stop(self) -> bool:
|
||||||
return self._should_stop.is_set()
|
return self._should_stop.is_set()
|
||||||
|
@ -52,31 +74,52 @@ class EntitiesEngine(Thread):
|
||||||
"""
|
"""
|
||||||
Trigger an EntityUpdateEvent if the entity has been persisted, or queue
|
Trigger an EntityUpdateEvent if the entity has been persisted, or queue
|
||||||
it to the list of entities whose notifications will be flushed when the
|
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:
|
for entity in entities:
|
||||||
get_bus().post(EntityUpdateEvent(entity=entity))
|
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):
|
def run(self):
|
||||||
super().run()
|
super().run()
|
||||||
set_thread_name('entities')
|
set_thread_name('entities')
|
||||||
self.logger.info('Started entities engine')
|
self.logger.info('Started entities engine')
|
||||||
|
self._running.set()
|
||||||
|
|
||||||
while not self.should_stop:
|
try:
|
||||||
# Get a batch of entity updates forwarded by other integrations
|
while not self.should_stop:
|
||||||
entities = self._queue.get()
|
# Get a batch of entity updates forwarded by other integrations
|
||||||
if not entities or self.should_stop:
|
entities = self._queue.get()
|
||||||
continue
|
if not entities or self.should_stop:
|
||||||
|
continue
|
||||||
|
|
||||||
# Store the batch of entities
|
# Store the batch of entities
|
||||||
try:
|
try:
|
||||||
entities = self._repo.save(*entities)
|
entities = self._repo.save(*entities)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error('Error while processing entity updates: %s', e)
|
self.logger.error('Error while processing entity updates: %s', e)
|
||||||
self.logger.exception(e)
|
self.logger.exception(e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Trigger EntityUpdateEvent events
|
# Trigger EntityUpdateEvent events
|
||||||
self.notify(*entities)
|
self.notify(*entities)
|
||||||
|
finally:
|
||||||
self.logger.info('Stopped entities engine')
|
self.logger.info('Stopped entities engine')
|
||||||
|
self._running.clear()
|
||||||
|
|
|
@ -37,7 +37,12 @@ class EntitiesRepository:
|
||||||
the taxonomies.
|
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._merger.merge(session, entities)
|
||||||
merged_entities = self._db.upsert(session, merged_entities)
|
merged_entities = self._db.upsert(session, merged_entities)
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ from dataclasses import dataclass
|
||||||
from typing import Dict, Iterable, List, Tuple
|
from typing import Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
from sqlalchemy.exc import InvalidRequestError
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from platypush.context import get_plugin
|
from platypush.context import get_plugin
|
||||||
|
@ -179,18 +178,12 @@ class EntitiesDb:
|
||||||
|
|
||||||
session.commit()
|
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()
|
}.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
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
from typing import Dict, Iterable, List, Optional, Tuple
|
from typing import Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, exc
|
||||||
|
|
||||||
from platypush.entities import Entity
|
from platypush.entities import Entity
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
class EntitiesMerger:
|
class EntitiesMerger:
|
||||||
"""
|
"""
|
||||||
This object is in charge of detecting and merging entities that already
|
This object is in charge of detecting and merging entities that already
|
||||||
|
@ -103,7 +104,12 @@ class EntitiesMerger:
|
||||||
the parent.
|
the parent.
|
||||||
"""
|
"""
|
||||||
parent_id: Optional[int] = entity.parent_id
|
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 the entity has a parent with an ID, use that
|
||||||
if parent and parent.id:
|
if parent and parent.id:
|
||||||
|
|
|
@ -5,7 +5,7 @@ from datetime import datetime
|
||||||
from typing import Any, Optional, Dict, Collection, Type
|
from typing import Any, Optional, Dict, Collection, Type
|
||||||
|
|
||||||
from platypush.config import Config
|
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
|
from platypush.utils import get_plugin_name_by_class, get_redis
|
||||||
|
|
||||||
_entity_registry_varname = '_platypush/plugin_entity_registry'
|
_entity_registry_varname = '_platypush/plugin_entity_registry'
|
||||||
|
@ -68,7 +68,7 @@ class EntityManager(ABC):
|
||||||
|
|
||||||
def _normalize_entities(self, entities: Collection[Entity]) -> Collection[Entity]:
|
def _normalize_entities(self, entities: Collection[Entity]) -> Collection[Entity]:
|
||||||
for entity in entities:
|
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 IDs can only refer to the internal primary key
|
||||||
entity.external_id = entity.id
|
entity.external_id = entity.id
|
||||||
entity.id = None # type: ignore
|
entity.id = None # type: ignore
|
||||||
|
@ -80,7 +80,9 @@ class EntityManager(ABC):
|
||||||
return entities
|
return entities
|
||||||
|
|
||||||
def publish_entities(
|
def publish_entities(
|
||||||
self, entities: Optional[Collection[Any]]
|
self,
|
||||||
|
entities: Optional[Collection[Any]],
|
||||||
|
callback: Optional[EntitySavedCallback] = None,
|
||||||
) -> Collection[Entity]:
|
) -> Collection[Entity]:
|
||||||
"""
|
"""
|
||||||
Publishes a list of entities. The downstream consumers include:
|
Publishes a list of entities. The downstream consumers include:
|
||||||
|
@ -91,6 +93,9 @@ class EntityManager(ABC):
|
||||||
:class:`platypush.message.event.entities.EntityUpdateEvent`
|
:class:`platypush.message.event.entities.EntityUpdateEvent`
|
||||||
events (e.g. web clients)
|
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
|
You usually don't need to override this class (but you may want to
|
||||||
extend :meth:`.transform_entities` instead if your extension doesn't
|
extend :meth:`.transform_entities` instead if your extension doesn't
|
||||||
natively handle `Entity` objects).
|
natively handle `Entity` objects).
|
||||||
|
@ -101,7 +106,7 @@ class EntityManager(ABC):
|
||||||
self.transform_entities(entities or [])
|
self.transform_entities(entities or [])
|
||||||
)
|
)
|
||||||
|
|
||||||
publish_entities(transformed_entities)
|
publish_entities(transformed_entities, callback=callback)
|
||||||
return transformed_entities
|
return transformed_entities
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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__,
|
|
||||||
}
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
from ._device import BluetoothDevice
|
||||||
|
from ._service import BluetoothService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BluetoothDevice",
|
||||||
|
"BluetoothService",
|
||||||
|
]
|
|
@ -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],
|
||||||
|
}
|
|
@ -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),
|
||||||
|
}
|
|
@ -53,6 +53,7 @@ if 'raw_sensor' not in Base.metadata:
|
||||||
If ``is_json`` is ``True``, then ``value`` is a JSON-encoded string
|
If ``is_json`` is ``True``, then ``value`` is a JSON-encoded string
|
||||||
object or array.
|
object or array.
|
||||||
"""
|
"""
|
||||||
|
unit = Column(String)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def value(self):
|
def value(self):
|
||||||
|
|
|
@ -7,6 +7,7 @@ import inspect
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
logger = logging.getLogger('platypush')
|
logger = logging.getLogger('platypush')
|
||||||
|
|
||||||
|
@ -60,6 +61,9 @@ class Message:
|
||||||
if isinstance(obj, set):
|
if isinstance(obj, set):
|
||||||
return list(obj)
|
return list(obj)
|
||||||
|
|
||||||
|
if isinstance(obj, UUID):
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
value = self.parse_numpy(obj)
|
value = self.parse_numpy(obj)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
return value
|
return value
|
||||||
|
|
|
@ -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
|
from platypush.message.event import Event
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,19 +28,7 @@ class BluetoothScanResumedEvent(BluetoothEvent):
|
||||||
super().__init__(*args, duration=duration, **kwargs)
|
super().__init__(*args, duration=duration, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class BluetoothWithPortEvent(Event):
|
class BluetoothDeviceEvent(BluetoothEvent):
|
||||||
"""
|
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
Base class for Bluetooth device events.
|
Base class for Bluetooth device events.
|
||||||
"""
|
"""
|
||||||
|
@ -49,59 +38,52 @@ class BluetoothDeviceEvent(BluetoothWithPortEvent):
|
||||||
*args,
|
*args,
|
||||||
address: str,
|
address: str,
|
||||||
connected: bool,
|
connected: bool,
|
||||||
paired: bool,
|
|
||||||
trusted: bool,
|
|
||||||
blocked: bool,
|
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
uuids: Optional[Dict[str, str]] = None,
|
|
||||||
rssi: Optional[int] = None,
|
rssi: Optional[int] = None,
|
||||||
tx_power: Optional[int] = None,
|
tx_power: Optional[int] = None,
|
||||||
manufacturers: Optional[Dict[int, str]] = None,
|
manufacturer: Optional[str] = None,
|
||||||
manufacturer_data: Optional[Dict[int, str]] = None,
|
services: Optional[Iterable[dict]] = None,
|
||||||
service_data: Optional[Dict[str, str]] = None,
|
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
:param address: The Bluetooth address of the device.
|
:param address: The Bluetooth address of the device.
|
||||||
:param connected: Whether the device is connected.
|
: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 name: The name of the device.
|
||||||
:param uuids: The UUIDs of the services exposed by the device.
|
|
||||||
:param rssi: Received Signal Strength Indicator.
|
:param rssi: Received Signal Strength Indicator.
|
||||||
:param tx_power: Transmission power.
|
:param tx_power: Transmission power.
|
||||||
:param manufacturers: The manufacturers published by the device, as a
|
:param manufacturers: The manufacturers published by the device, as a
|
||||||
``manufacturer_id -> registered_name`` map.
|
``manufacturer_id -> registered_name`` map.
|
||||||
:param manufacturer_data: The manufacturer data published by the
|
:param services: The services published by the device.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
*args,
|
*args,
|
||||||
address=address,
|
address=address,
|
||||||
name=name,
|
name=name,
|
||||||
connected=connected,
|
connected=connected,
|
||||||
paired=paired,
|
|
||||||
blocked=blocked,
|
|
||||||
trusted=trusted,
|
|
||||||
uuids=uuids or {},
|
|
||||||
rssi=rssi,
|
rssi=rssi,
|
||||||
tx_power=tx_power,
|
tx_power=tx_power,
|
||||||
manufacturers=manufacturers or {},
|
manufacturer=manufacturer,
|
||||||
manufacturer_data=manufacturer_data or {},
|
services=services,
|
||||||
service_data=service_data or {},
|
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_device(cls, device: BluetoothDevice, **kwargs) -> "BluetoothDeviceEvent":
|
||||||
|
"""
|
||||||
|
Initialize a Bluetooth event from the parameters of a device.
|
||||||
|
|
||||||
class BluetoothDeviceNewDataEvent(BluetoothDeviceEvent):
|
:param device: Bluetooth device.
|
||||||
"""
|
"""
|
||||||
Event triggered when a Bluetooth device publishes new manufacturer/service
|
return cls(
|
||||||
data.
|
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):
|
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):
|
class BluetoothDeviceSignalUpdateEvent(BluetoothDeviceEvent):
|
||||||
"""
|
"""
|
||||||
Event triggered when the RSSI/TX power of a Bluetooth device is updated.
|
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.
|
Event triggered when a file put request is received.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BluetoothFileGetRequestEvent(BluetoothWithPortEvent):
|
class BluetoothFileGetRequestEvent(BluetoothFileEvent):
|
||||||
"""
|
"""
|
||||||
Event triggered when a file get request is received.
|
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:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -7,7 +7,7 @@ from platypush.message.event import Event
|
||||||
class EntityEvent(Event):
|
class EntityEvent(Event):
|
||||||
def __init__(self, entity: Union[Entity, dict], *args, **kwargs):
|
def __init__(self, entity: Union[Entity, dict], *args, **kwargs):
|
||||||
if isinstance(entity, Entity):
|
if isinstance(entity, Entity):
|
||||||
entity = entity.to_json()
|
entity = entity.to_dict()
|
||||||
super().__init__(entity=entity, *args, **kwargs)
|
super().__init__(entity=entity, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -78,7 +78,7 @@ class Response(Message):
|
||||||
the message into a UTF-8 JSON string
|
the message into a UTF-8 JSON string
|
||||||
"""
|
"""
|
||||||
output = (
|
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 = {
|
response_dict = {
|
||||||
|
|
|
@ -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:
|
|
|
@ -80,13 +80,13 @@ class RunnablePlugin(Plugin):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
poll_interval: Optional[float] = 30,
|
poll_interval: Optional[float] = 15,
|
||||||
stop_timeout: Optional[float] = PLUGIN_STOP_TIMEOUT,
|
stop_timeout: Optional[float] = PLUGIN_STOP_TIMEOUT,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
:param poll_interval: How often the :meth:`.loop` function should be
|
: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
|
:param stop_timeout: How long we should wait for any running
|
||||||
threads/processes to stop before exiting (default: 5 seconds).
|
threads/processes to stop before exiting (default: 5 seconds).
|
||||||
"""
|
"""
|
||||||
|
@ -126,7 +126,11 @@ class RunnablePlugin(Plugin):
|
||||||
Stop the plugin.
|
Stop the plugin.
|
||||||
"""
|
"""
|
||||||
self._should_stop.set()
|
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')
|
self.logger.info('Waiting for the plugin to stop')
|
||||||
try:
|
try:
|
||||||
if self._thread:
|
if self._thread:
|
||||||
|
|
|
@ -1,434 +1,3 @@
|
||||||
import base64
|
from ._plugin import BluetoothPlugin
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import select
|
|
||||||
|
|
||||||
from typing import Dict, Optional
|
__all__ = ["BluetoothPlugin"]
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
from ._manager import BLEManager
|
||||||
|
|
||||||
|
__all__ = ["BLEManager"]
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
|
@ -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()
|
|
@ -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
|
|
@ -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)
|
|
@ -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:
|
|
@ -1,11 +1,10 @@
|
||||||
import json
|
import json
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass, field
|
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.device import BLEDevice
|
||||||
from bleak.backends.scanner import AdvertisementData
|
from bleak.backends.scanner import AdvertisementData
|
||||||
from bleak.uuids import uuidstr_to_str
|
|
||||||
from bluetooth_numbers import company
|
from bluetooth_numbers import company
|
||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# 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 import Entity
|
||||||
from platypush.entities.batteries import Battery
|
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.contact import ContactSensor
|
||||||
from platypush.entities.electricity import (
|
from platypush.entities.electricity import (
|
||||||
CurrentSensor,
|
CurrentSensor,
|
||||||
|
@ -33,6 +32,8 @@ from platypush.entities.temperature import TemperatureSensor
|
||||||
from platypush.entities.time import TimeDurationSensor
|
from platypush.entities.time import TimeDurationSensor
|
||||||
from platypush.entities.weight import WeightSensor
|
from platypush.entities.weight import WeightSensor
|
||||||
|
|
||||||
|
from .._model import Protocol, ServiceClass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TheengsEntity:
|
class TheengsEntity:
|
||||||
|
@ -42,7 +43,7 @@ class TheengsEntity:
|
||||||
|
|
||||||
data: dict = field(default_factory=dict)
|
data: dict = field(default_factory=dict)
|
||||||
properties: dict = field(default_factory=dict)
|
properties: dict = field(default_factory=dict)
|
||||||
brand: Optional[str] = None
|
manufacturer: Optional[str] = None
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
model_id: 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:
|
def device_to_entity(device: BLEDevice, data: AdvertisementData) -> BluetoothDevice:
|
||||||
"""
|
"""
|
||||||
Convert the data received from a Bluetooth advertisement packet into a
|
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)
|
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(
|
parent_entity = BluetoothDevice(
|
||||||
id=device.address,
|
id=device.address,
|
||||||
model=theengs_entity.model,
|
model=theengs_entity.model,
|
||||||
brand=theengs_entity.brand,
|
|
||||||
reachable=True,
|
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 = {
|
parsed_entities = {
|
||||||
|
@ -239,6 +282,7 @@ def device_to_entity(device: BLEDevice, data: AdvertisementData) -> BluetoothDev
|
||||||
entity.id = f'{parent_entity.id}:{prop}'
|
entity.id = f'{parent_entity.id}:{prop}'
|
||||||
entity.name = prop
|
entity.name = prop
|
||||||
parent_entity.children.append(entity)
|
parent_entity.children.append(entity)
|
||||||
|
entity.parent = parent_entity
|
||||||
|
|
||||||
return parent_entity
|
return parent_entity
|
||||||
|
|
||||||
|
@ -250,7 +294,7 @@ def _parse_advertisement_data(data: AdvertisementData) -> TheengsEntity:
|
||||||
maps the parsed attributes.
|
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:
|
if data.service_data:
|
||||||
parsed_data = list(data.service_data.keys())[0]
|
parsed_data = list(data.service_data.keys())[0]
|
||||||
|
@ -286,67 +330,7 @@ def _parse_advertisement_data(data: AdvertisementData) -> TheengsEntity:
|
||||||
return TheengsEntity(
|
return TheengsEntity(
|
||||||
data=entity_args,
|
data=entity_args,
|
||||||
properties=properties,
|
properties=properties,
|
||||||
brand=brand,
|
manufacturer=manufacturer,
|
||||||
model=model,
|
model=model,
|
||||||
model_id=model_id,
|
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()
|
|
||||||
}
|
|
|
@ -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())
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .sender import FileSender
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FileSender"]
|
|
@ -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))
|
|
@ -0,0 +1,3 @@
|
||||||
|
from ._manager import LegacyManager
|
||||||
|
|
||||||
|
__all__ = ["LegacyManager"]
|
|
@ -0,0 +1,3 @@
|
||||||
|
from ._base import LegacyManager
|
||||||
|
|
||||||
|
__all__ = ["LegacyManager"]
|
|
@ -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')
|
|
@ -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)
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionId = Tuple[str, int]
|
||||||
|
""" (address, port) pair. """
|
|
@ -0,0 +1,8 @@
|
||||||
|
from ._device import BluetoothDeviceBuilder
|
||||||
|
from ._services import BluetoothServicesBuilder
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BluetoothDeviceBuilder",
|
||||||
|
"BluetoothServicesBuilder",
|
||||||
|
]
|
|
@ -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())
|
|
@ -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())
|
|
@ -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:
|
|
@ -0,0 +1,11 @@
|
||||||
|
from ._classes import MajorDeviceClass, MajorServiceClass, MinorDeviceClass
|
||||||
|
from ._protocol import Protocol
|
||||||
|
from ._service import ServiceClass
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MajorDeviceClass",
|
||||||
|
"MajorServiceClass",
|
||||||
|
"MinorDeviceClass",
|
||||||
|
"Protocol",
|
||||||
|
"ServiceClass",
|
||||||
|
]
|
|
@ -0,0 +1,9 @@
|
||||||
|
from ._device import MajorDeviceClass, MinorDeviceClass
|
||||||
|
from ._service import MajorServiceClass
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MajorDeviceClass",
|
||||||
|
"MinorDeviceClass",
|
||||||
|
"MajorServiceClass",
|
||||||
|
]
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
||||||
|
from ._major import MajorDeviceClass
|
||||||
|
from ._minor import MinorDeviceClass
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MajorDeviceClass",
|
||||||
|
"MinorDeviceClass",
|
||||||
|
]
|
|
@ -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)
|
|
@ -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
|
||||||
|
)
|
|
@ -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)
|
|
@ -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)
|
|
@ -0,0 +1,6 @@
|
||||||
|
from ._directory import ServiceClass
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ServiceClass",
|
||||||
|
]
|
|
@ -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. """
|
|
@ -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``.
|
||||||
|
"""
|
|
@ -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:
|
|
@ -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.
|
||||||
|
"""
|
|
@ -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:
|
|
|
@ -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
|
|
|
@ -1,8 +1,23 @@
|
||||||
manifest:
|
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:
|
install:
|
||||||
pip:
|
pip:
|
||||||
- pybluez
|
- bleak
|
||||||
- pyobex
|
- bluetooth-numbers
|
||||||
|
- git+https://github.com/pybluez/pybluez
|
||||||
|
- git+https://github.com/theengs/gateway
|
||||||
|
- git+https://github.com/BlackLight/PyOBEX
|
||||||
package: platypush.plugins.bluetooth
|
package: platypush.plugins.bluetooth
|
||||||
type: plugin
|
type: plugin
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
from ._model import (
|
||||||
|
MajorDeviceClass,
|
||||||
|
MajorServiceClass,
|
||||||
|
MinorDeviceClass,
|
||||||
|
Protocol,
|
||||||
|
ServiceClass,
|
||||||
|
)
|
||||||
|
from ._types import RawServiceClass
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MajorDeviceClass",
|
||||||
|
"MajorServiceClass",
|
||||||
|
"MinorDeviceClass",
|
||||||
|
"Protocol",
|
||||||
|
"RawServiceClass",
|
||||||
|
"ServiceClass",
|
||||||
|
]
|
5
setup.py
5
setup.py
|
@ -177,8 +177,9 @@ setup(
|
||||||
'bluetooth': [
|
'bluetooth': [
|
||||||
'bleak',
|
'bleak',
|
||||||
'bluetooth-numbers',
|
'bluetooth-numbers',
|
||||||
'pybluez',
|
'pybluez @ https://github.com/pybluez/pybluez/tarball/master',
|
||||||
'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master',
|
'PyOBEX @ https://github.com/BlackLight/PyOBEX/tarball/master',
|
||||||
|
'TheengsGateway @ https://github.com/theengs/gateway/tarball/development',
|
||||||
],
|
],
|
||||||
# Support for TP-Link devices
|
# Support for TP-Link devices
|
||||||
'tplink': ['pyHS100'],
|
'tplink': ['pyHS100'],
|
||||||
|
|
Loading…
Reference in New Issue