[WIP] Big, big refactor of the Bluetooth integration.

- Merged together Bluetooth legacy and BLE plugins and scanners.
- Introduced Theengs as a dependency to infer BLE device types and
  create sub-entities appropriately.
- Using `BluetoothDevice` and `BluetoothService` entities as the bread
  and butter for all the Bluetooth plugin's components.
- Using a shared cache of devices and services between the legacy and
  BLE integrations, with merging/coalescing logic included.
- Extended list of discoverable services to include all those officially
  supported by the Bluetooth specs.
- Instantiate a separate pool of workers to discover services.
- Refactor of the Bluetooth events - all of them are now instantiated
  from a single `BluetoothDevice` object.
This commit is contained in:
Fabio Manganiello 2023-03-13 02:31:21 +01:00
parent 4bc61133c5
commit 2411b961e8
Signed by: blacklight
GPG key ID: D90FBA7F76362774
46 changed files with 2762 additions and 1672 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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
data.
""" """
return cls(
address=device.address,
name=device.name,
connected=device.connected,
rssi=device.rssi,
tx_power=device.tx_power,
manufacturer=device.manufacturer,
services=[srv.to_dict() for srv in device.services],
**kwargs
)
class BluetoothDeviceFoundEvent(BluetoothDeviceEvent): 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:

View file

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

View file

@ -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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,10 @@
import json import 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()
}

View file

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

View file

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

View file

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

View file

@ -1,11 +1,3 @@
from ._model import BluetoothDevice from ._manager import LegacyManager
from ._scanner import DeviceScanner
__all__ = ["LegacyManager"]
__all__ = [
"BluetoothDevice",
"DeviceScanner",
]
# vim:sw=4:ts=4:et:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
from ._base import BluetoothService
__all__ = ['BluetoothService']

View file

@ -1,127 +0,0 @@
from dataclasses import dataclass
from typing import Any, Dict, Iterable, Optional, Set, Tuple
from uuid import UUID
from .._protocol import Protocol
from ._directory import ServiceClass
from ._types import RawServiceClass
VersionedServices = Dict[ServiceClass, Optional[int]]
""" Service -> Version mapping. """
@dataclass
class BluetoothService:
"""
Models a discovered Bluetooth service.
"""
address: str
""" The address of the service that exposes the service. """
port: int
""" The Bluetooth port associated to the service. """
protocol: Protocol
""" The service protocol. """
name: Optional[str]
""" The name of the service. """
description: Optional[str]
""" The description of the service. """
service_id: Optional[str]
""" The ID of the service. """
service_classes: VersionedServices
"""
The compatible classes exposed by the service - see
https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Assigned%20Numbers.pdf,
Section 5.
"""
unknown_service_classes: Iterable[RawServiceClass]
""" Service classes that are not supported. """
@classmethod
def build(cls, service: Dict[str, Any]) -> 'BluetoothService':
"""
Builds a :class:`BluetoothService` from a service dictionary returned by
pybluez.
"""
return cls(
address=service['host'],
port=service['port'],
protocol=Protocol(service['protocol']),
name=service['name'],
description=service['description'],
service_id=service['service-id'],
service_classes=cls._parse_services(
service['service-classes'], service['profiles']
),
unknown_service_classes=cls._parse_unknown_services(
service['service-classes'],
),
)
@classmethod
def _parse_services(
cls, service_classes: Iterable[str], profiles: Iterable[Tuple[str, int]]
) -> VersionedServices:
"""
Parses the services.
:param service_classes: The service classes returned by pybluez.
:param profiles: The profiles returned by pybluez as a list of
``[(service, version)]`` tuples.
:return: A list of parsed service classes.
"""
# Parse the service classes
parsed_services: Dict[RawServiceClass, ServiceClass] = {}
for srv in service_classes:
srv_class = cls._parse_service_class(srv)
parsed_services[srv_class.value] = srv_class
# Parse the service classes versions
versioned_classes: VersionedServices = {}
for srv, version in profiles:
value = cls._parse_service_class(srv).value
parsed_srv = parsed_services.get(value)
if parsed_srv:
versioned_classes[parsed_srv] = version
return {
srv: versioned_classes.get(srv)
for srv in parsed_services.values()
if srv != ServiceClass.UNKNOWN
}
@classmethod
def _parse_unknown_services(
cls, service_classes: Iterable[str]
) -> Set[RawServiceClass]:
return {
cls._uuid(srv)
for srv in service_classes
if cls._parse_service_class(srv) == ServiceClass.UNKNOWN
}
@classmethod
def _parse_service_class(cls, srv: str) -> ServiceClass:
"""
:param srv: The service class returned by pybluez as a string (either
hex-encoded or UUID).
:return: The parsed :class:`ServiceClass` object or ``ServiceClass.UNKNOWN``.
"""
srv_class: ServiceClass = ServiceClass.UNKNOWN
try:
srv_class = ServiceClass.get(cls._uuid(srv))
except (TypeError, ValueError):
pass
return srv_class
@staticmethod
def _uuid(s: str) -> RawServiceClass:
"""
:param s: The service class returned by pybluez as a string.
:return: The UUID of the service class as a 16-bit or 128-bit identifier.
"""
try:
return UUID(s)
except ValueError:
return int(s, 16)

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ class Protocol(Enum):
Models a Bluetooth protocol. Models a Bluetooth protocol.
""" """
UNKNOWN = 'UNKNOWN'
RFCOMM = 'RFCOMM' RFCOMM = 'RFCOMM'
L2CAP = 'L2CAP' L2CAP = 'L2CAP'
TCP = 'TCP' TCP = 'TCP'

View file

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

View file

@ -1,10 +1,12 @@
import re import re
from enum import Enum from enum import Enum
from typing import Dict from typing import Dict
from uuid import UUID
import bluetooth_numbers import bluetooth_numbers
from bleak.uuids import uuid16_dict, uuid128_dict
from ._types import RawServiceClass from platypush.plugins.bluetooth._types import RawServiceClass
def _service_name_to_enum_name(service_name: str) -> str: def _service_name_to_enum_name(service_name: str) -> str:
@ -111,6 +113,10 @@ Section 3.3.
# Section 3.4 # Section 3.4
_service_classes.update(bluetooth_numbers.service) _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] = { _service_classes_by_name: Dict[str, RawServiceClass] = {
name: cls for cls, name in _service_classes.items() name: cls for cls, name in _service_classes.items()
} }
@ -134,6 +140,12 @@ class _ServiceClassMeta:
try: try:
return ServiceClass(value) return ServiceClass(value)
except ValueError: except ValueError:
try:
if isinstance(value, UUID):
return ServiceClass(int(str(value).upper()[4:8], 16))
except ValueError:
pass
return ServiceClass.UNKNOWN # type: ignore return ServiceClass.UNKNOWN # type: ignore
@classmethod @classmethod
@ -149,7 +161,9 @@ class _ServiceClassMeta:
) )
def __str__(self) -> str: def __str__(self) -> str:
return _service_classes.get(self.value, ServiceClass(0).value) return _service_classes.get(
self.value, ServiceClass.UNKNOWN.value # type: ignore
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<{self.value}: {str(self)}>" return f"<{self.value}: {str(self)}>"

View file

@ -2,7 +2,7 @@
from enum import Enum from enum import Enum
from ._types import RawServiceClass from platypush.plugins.bluetooth._types import RawServiceClass
class ServiceClass(Enum): class ServiceClass(Enum):
""" """
@ -14,6 +14,8 @@ class ServiceClass(Enum):
UNKNOWN = ... UNKNOWN = ...
""" A class for unknown services. """ """ A class for unknown services. """
OBEX_OBJECT_PUSH = ...
""" Class for the OBEX Object Push service. """
@classmethod @classmethod
def get(cls, value: RawServiceClass) -> "ServiceClass": def get(cls, value: RawServiceClass) -> "ServiceClass":

View file

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

View file

@ -1,628 +0,0 @@
import base64
import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass
from threading import RLock, Timer
import threading
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]
@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
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.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`
* :class:`platypush.message.event.entities.EntityUpdateEvent`
"""
_default_connect_timeout: Final[int] = 10
""" 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: 10 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 = asyncio.Event()
self._scan_controller_timer: Optional[Timer] = None
self._connections: Dict[str, _BluetoothConnection] = {}
self._connection_locks: Dict[str, asyncio.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
):
if event_type == BluetoothDeviceNewDataEvent:
# Skip BluetoothDeviceNewDataEvent. They are only generated to
# infer if the device has sent some new data, but they can be very
# noisy and affect the latency - especially in case of connections
# that exchange a lot of data.
return
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()
# Explicitly connect the child entities to their parent before
# flushing them
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,
close_event: Optional[asyncio.Event] = None,
) -> AsyncGenerator[_BluetoothConnection, None]:
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,
) as client:
assert client, f'Could not connect to the device {device}'
self.logger.info('Connected to device %s', device)
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]
self._connections.pop(dev.address, None)
self.logger.info('Disconnected from device %s', device)
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 conn:
data = await conn.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 conn:
await conn.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 connect(
self,
device: str,
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 interface: The Bluetooth interface to use (it overrides the
default ``interface``).
:param timeout: The connection timeout in seconds (it overrides the
default ``connect_timeout``).
"""
timeout = timeout or self._connect_timeout
connected_event = threading.Event()
close_event = asyncio.Event()
loop = asyncio.new_event_loop()
def connect_thread():
async def 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())
loop = get_or_create_event_loop()
connector = threading.Thread(target=connect_thread)
connector.start()
success = connected_event.wait(timeout=timeout)
assert success, f'Connection to {device} timed out'
@action
def disconnect(self, device: str):
"""
Close an active connection to a device.
:param device: The device address or name.
"""
address: Optional[str] = device
if not self._connections.get(device):
address = self._device_addr_by_name.get(device)
assert address, f'Device {device} not found'
connection = self._connections.get(address, None)
assert connection, f'No active connections to the device {device} were found'
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'
@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:
asyncio.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:
asyncio.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()
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
)
super().stop()
# vim:sw=4:ts=4:et:

View file

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

View file

@ -1,8 +1,23 @@
manifest: 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

View file

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

View file

@ -177,8 +177,9 @@ setup(
'bluetooth': [ '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'],