forked from platypush/platypush
[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:
parent
4bc61133c5
commit
2411b961e8
46 changed files with 2762 additions and 1672 deletions
|
@ -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__,
|
|
||||||
}
|
|
7
platypush/entities/bluetooth/__init__.py
Normal file
7
platypush/entities/bluetooth/__init__.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from ._device import BluetoothDevice
|
||||||
|
from ._service import BluetoothService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BluetoothDevice",
|
||||||
|
"BluetoothService",
|
||||||
|
]
|
167
platypush/entities/bluetooth/_device.py
Normal file
167
platypush/entities/bluetooth/_device.py
Normal 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],
|
||||||
|
}
|
133
platypush/entities/bluetooth/_service.py
Normal file
133
platypush/entities/bluetooth/_service.py
Normal 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),
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
from typing import Dict, Optional
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from platypush.entities.bluetooth import BluetoothDevice
|
||||||
from platypush.message.event import Event
|
from platypush.message.event import Event
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,19 +28,7 @@ class BluetoothScanResumedEvent(BluetoothEvent):
|
||||||
super().__init__(*args, duration=duration, **kwargs)
|
super().__init__(*args, duration=duration, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class BluetoothWithPortEvent(Event):
|
class BluetoothDeviceEvent(BluetoothEvent):
|
||||||
"""
|
|
||||||
Base class for Bluetooth events with an associated port.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, port: Optional[str] = None, **kwargs):
|
|
||||||
"""
|
|
||||||
:param port: The communication port of the device.
|
|
||||||
"""
|
|
||||||
super().__init__(*args, port=port, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDeviceEvent(BluetoothWithPortEvent):
|
|
||||||
"""
|
"""
|
||||||
Base class for Bluetooth device events.
|
Base class for Bluetooth device events.
|
||||||
"""
|
"""
|
||||||
|
@ -49,59 +38,52 @@ class BluetoothDeviceEvent(BluetoothWithPortEvent):
|
||||||
*args,
|
*args,
|
||||||
address: str,
|
address: str,
|
||||||
connected: bool,
|
connected: bool,
|
||||||
paired: bool,
|
|
||||||
trusted: bool,
|
|
||||||
blocked: bool,
|
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
uuids: Optional[Dict[str, str]] = None,
|
|
||||||
rssi: Optional[int] = None,
|
rssi: Optional[int] = None,
|
||||||
tx_power: Optional[int] = None,
|
tx_power: Optional[int] = None,
|
||||||
manufacturers: Optional[Dict[int, str]] = None,
|
manufacturer: Optional[str] = None,
|
||||||
manufacturer_data: Optional[Dict[int, str]] = None,
|
services: Optional[Iterable[dict]] = None,
|
||||||
service_data: Optional[Dict[str, str]] = None,
|
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
:param address: The Bluetooth address of the device.
|
:param address: The Bluetooth address of the device.
|
||||||
:param connected: Whether the device is connected.
|
:param connected: Whether the device is connected.
|
||||||
:param paired: Whether the device is paired.
|
|
||||||
:param trusted: Whether the device is trusted.
|
|
||||||
:param blocked: Whether the device is blocked.
|
|
||||||
:param name: The name of the device.
|
:param name: The name of the device.
|
||||||
:param uuids: The UUIDs of the services exposed by the device.
|
|
||||||
:param rssi: Received Signal Strength Indicator.
|
:param rssi: Received Signal Strength Indicator.
|
||||||
:param tx_power: Transmission power.
|
:param tx_power: Transmission power.
|
||||||
:param manufacturers: The manufacturers published by the device, as a
|
:param manufacturers: The manufacturers published by the device, as a
|
||||||
``manufacturer_id -> registered_name`` map.
|
``manufacturer_id -> registered_name`` map.
|
||||||
:param manufacturer_data: The manufacturer data published by the
|
:param services: The services published by the device.
|
||||||
device, as a ``manufacturer_id -> data`` map, where ``data`` is a
|
|
||||||
hexadecimal string.
|
|
||||||
:param service_data: The service data published by the device, as a
|
|
||||||
``service_uuid -> data`` map, where ``data`` is a hexadecimal string.
|
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
*args,
|
*args,
|
||||||
address=address,
|
address=address,
|
||||||
name=name,
|
name=name,
|
||||||
connected=connected,
|
connected=connected,
|
||||||
paired=paired,
|
|
||||||
blocked=blocked,
|
|
||||||
trusted=trusted,
|
|
||||||
uuids=uuids or {},
|
|
||||||
rssi=rssi,
|
rssi=rssi,
|
||||||
tx_power=tx_power,
|
tx_power=tx_power,
|
||||||
manufacturers=manufacturers or {},
|
manufacturer=manufacturer,
|
||||||
manufacturer_data=manufacturer_data or {},
|
services=services,
|
||||||
service_data=service_data or {},
|
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_device(cls, device: BluetoothDevice, **kwargs) -> "BluetoothDeviceEvent":
|
||||||
|
"""
|
||||||
|
Initialize a Bluetooth event from the parameters of a device.
|
||||||
|
|
||||||
class BluetoothDeviceNewDataEvent(BluetoothDeviceEvent):
|
:param device: Bluetooth device.
|
||||||
"""
|
"""
|
||||||
Event triggered when a Bluetooth device publishes new manufacturer/service
|
return cls(
|
||||||
data.
|
address=device.address,
|
||||||
"""
|
name=device.name,
|
||||||
|
connected=device.connected,
|
||||||
|
rssi=device.rssi,
|
||||||
|
tx_power=device.tx_power,
|
||||||
|
manufacturer=device.manufacturer,
|
||||||
|
services=[srv.to_dict() for srv in device.services],
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDeviceFoundEvent(BluetoothDeviceEvent):
|
class BluetoothDeviceFoundEvent(BluetoothDeviceEvent):
|
||||||
|
@ -128,73 +110,61 @@ class BluetoothDeviceDisconnectedEvent(BluetoothDeviceEvent):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDevicePairedEvent(BluetoothDeviceEvent):
|
|
||||||
"""
|
|
||||||
Event triggered when a Bluetooth device is paired.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDeviceUnpairedEvent(BluetoothDeviceEvent):
|
|
||||||
"""
|
|
||||||
Event triggered when a Bluetooth device is unpaired.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDeviceBlockedEvent(BluetoothDeviceEvent):
|
|
||||||
"""
|
|
||||||
Event triggered when a Bluetooth device is blocked.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDeviceUnblockedEvent(BluetoothDeviceEvent):
|
|
||||||
"""
|
|
||||||
Event triggered when a Bluetooth device is unblocked.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDeviceTrustedEvent(BluetoothDeviceEvent):
|
|
||||||
"""
|
|
||||||
Event triggered when a Bluetooth device is trusted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDeviceSignalUpdateEvent(BluetoothDeviceEvent):
|
class BluetoothDeviceSignalUpdateEvent(BluetoothDeviceEvent):
|
||||||
"""
|
"""
|
||||||
Event triggered when the RSSI/TX power of a Bluetooth device is updated.
|
Event triggered when the RSSI/TX power of a Bluetooth device is updated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BluetoothDeviceUntrustedEvent(BluetoothDeviceEvent):
|
class BluetoothConnectionFailedEvent(BluetoothDeviceEvent):
|
||||||
"""
|
"""
|
||||||
Event triggered when a Bluetooth device is untrusted.
|
Event triggered when a Bluetooth connection fails.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BluetoothConnectionRejectedEvent(BluetoothDeviceEvent):
|
class BluetoothFileEvent(BluetoothDeviceEvent):
|
||||||
"""
|
"""
|
||||||
Event triggered when a Bluetooth connection is rejected.
|
Base class for Bluetooth file events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, file: str, **kwargs):
|
||||||
|
super().__init__(*args, file=file, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class BluetoothFileTransferStartedEvent(BluetoothFileEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a file transfer is initiated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BluetoothFilePutRequestEvent(BluetoothWithPortEvent):
|
class BluetoothFileTransferCancelledEvent(BluetoothFileEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a file transfer is cancelled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BluetoothFileReceivedEvent(BluetoothFileEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a file download is completed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BluetoothFileSentEvent(BluetoothFileEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a file upload is completed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BluetoothFilePutRequestEvent(BluetoothFileEvent):
|
||||||
"""
|
"""
|
||||||
Event triggered when a file put request is received.
|
Event triggered when a file put request is received.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BluetoothFileGetRequestEvent(BluetoothWithPortEvent):
|
class BluetoothFileGetRequestEvent(BluetoothFileEvent):
|
||||||
"""
|
"""
|
||||||
Event triggered when a file get request is received.
|
Event triggered when a file get request is received.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BluetoothFileReceivedEvent(BluetoothEvent):
|
|
||||||
"""
|
|
||||||
Event triggered when a file transfer is completed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, path: str, **kwargs):
|
|
||||||
super().__init__(*args, path=path, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -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:
|
|
|
@ -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:
|
|
||||||
|
|
5
platypush/plugins/bluetooth/_ble/__init__.py
Normal file
5
platypush/plugins/bluetooth/_ble/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from ._manager import BLEManager
|
||||||
|
|
||||||
|
__all__ = ["BLEManager"]
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
41
platypush/plugins/bluetooth/_ble/_cache.py
Normal file
41
platypush/plugins/bluetooth/_ble/_cache.py
Normal 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()
|
20
platypush/plugins/bluetooth/_ble/_connection.py
Normal file
20
platypush/plugins/bluetooth/_ble/_connection.py
Normal 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
|
135
platypush/plugins/bluetooth/_ble/_event_handler.py
Normal file
135
platypush/plugins/bluetooth/_ble/_event_handler.py
Normal 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)
|
375
platypush/plugins/bluetooth/_ble/_manager.py
Normal file
375
platypush/plugins/bluetooth/_ble/_manager.py
Normal 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:
|
|
@ -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()
|
|
||||||
}
|
|
207
platypush/plugins/bluetooth/_cache.py
Normal file
207
platypush/plugins/bluetooth/_cache.py
Normal 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())
|
4
platypush/plugins/bluetooth/_file/__init__.py
Normal file
4
platypush/plugins/bluetooth/_file/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from .sender import FileSender
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FileSender"]
|
105
platypush/plugins/bluetooth/_file/sender.py
Normal file
105
platypush/plugins/bluetooth/_file/sender.py
Normal 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))
|
|
@ -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:
|
|
||||||
|
|
3
platypush/plugins/bluetooth/_legacy/_manager/__init__.py
Normal file
3
platypush/plugins/bluetooth/_legacy/_manager/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from ._base import LegacyManager
|
||||||
|
|
||||||
|
__all__ = ["LegacyManager"]
|
346
platypush/plugins/bluetooth/_legacy/_manager/_base.py
Normal file
346
platypush/plugins/bluetooth/_legacy/_manager/_base.py
Normal 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')
|
64
platypush/plugins/bluetooth/_legacy/_manager/_connection.py
Normal file
64
platypush/plugins/bluetooth/_legacy/_manager/_connection.py
Normal 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)
|
64
platypush/plugins/bluetooth/_legacy/_manager/_service.py
Normal file
64
platypush/plugins/bluetooth/_legacy/_manager/_service.py
Normal 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
|
5
platypush/plugins/bluetooth/_legacy/_manager/_types.py
Normal file
5
platypush/plugins/bluetooth/_legacy/_manager/_types.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionId = Tuple[str, int]
|
||||||
|
""" (address, port) pair. """
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
76
platypush/plugins/bluetooth/_legacy/_model/_device.py
Normal file
76
platypush/plugins/bluetooth/_legacy/_model/_device.py
Normal 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())
|
|
@ -1,4 +0,0 @@
|
||||||
from ._base import BluetoothService
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['BluetoothService']
|
|
|
@ -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)
|
|
47
platypush/plugins/bluetooth/_legacy/_model/_services.py
Normal file
47
platypush/plugins/bluetooth/_legacy/_model/_services.py
Normal 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())
|
163
platypush/plugins/bluetooth/_manager.py
Normal file
163
platypush/plugins/bluetooth/_manager.py
Normal 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:
|
11
platypush/plugins/bluetooth/_model/__init__.py
Normal file
11
platypush/plugins/bluetooth/_model/__init__.py
Normal 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",
|
||||||
|
]
|
|
@ -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'
|
6
platypush/plugins/bluetooth/_model/_service/__init__.py
Normal file
6
platypush/plugins/bluetooth/_model/_service/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from ._directory import ServiceClass
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ServiceClass",
|
||||||
|
]
|
|
@ -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)}>"
|
|
@ -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":
|
603
platypush/plugins/bluetooth/_plugin.py
Normal file
603
platypush/plugins/bluetooth/_plugin.py
Normal 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:
|
|
@ -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:
|
|
|
@ -1,22 +0,0 @@
|
||||||
manifest:
|
|
||||||
events:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceBlockedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceFoundEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceLostEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceNewDataEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDevicePairedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceTrustedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceUnblockedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceUnpairedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothDeviceUntrustedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothScanPausedEvent:
|
|
||||||
platypush.message.event.bluetooth.BluetoothScanResumedEvent:
|
|
||||||
install:
|
|
||||||
pip:
|
|
||||||
- bleak
|
|
||||||
- bluetooth-numbers
|
|
||||||
- git+https://github.com/BlackLight/TheengsGateway
|
|
||||||
package: platypush.plugins.bluetooth.ble
|
|
||||||
type: plugin
|
|
|
@ -1,8 +1,23 @@
|
||||||
manifest:
|
manifest:
|
||||||
events: {}
|
events:
|
||||||
|
platypush.message.event.bluetooth.BluetoothConnectionFailedEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothDeviceFoundEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothDeviceLostEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothFileReceivedEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothFileSentEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothFileTransferCancelledEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothFileTransferStartedEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothScanPausedEvent:
|
||||||
|
platypush.message.event.bluetooth.BluetoothScanResumedEvent:
|
||||||
|
platypush.message.event.entities.EntityUpdateEvent:
|
||||||
install:
|
install:
|
||||||
pip:
|
pip:
|
||||||
- pybluez
|
- bleak
|
||||||
- pyobex
|
- bluetooth-numbers
|
||||||
|
- git+https://github.com/pybluez/pybluez
|
||||||
|
- git+https://github.com/theengs/gateway
|
||||||
|
- git+https://github.com/BlackLight/PyOBEX
|
||||||
package: platypush.plugins.bluetooth
|
package: platypush.plugins.bluetooth
|
||||||
type: plugin
|
type: plugin
|
||||||
|
|
17
platypush/plugins/bluetooth/model.py
Normal file
17
platypush/plugins/bluetooth/model.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
from ._model import (
|
||||||
|
MajorDeviceClass,
|
||||||
|
MajorServiceClass,
|
||||||
|
MinorDeviceClass,
|
||||||
|
Protocol,
|
||||||
|
ServiceClass,
|
||||||
|
)
|
||||||
|
from ._types import RawServiceClass
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MajorDeviceClass",
|
||||||
|
"MajorServiceClass",
|
||||||
|
"MinorDeviceClass",
|
||||||
|
"Protocol",
|
||||||
|
"RawServiceClass",
|
||||||
|
"ServiceClass",
|
||||||
|
]
|
5
setup.py
5
setup.py
|
@ -177,8 +177,9 @@ setup(
|
||||||
'bluetooth': [
|
'bluetooth': [
|
||||||
'bleak',
|
'bleak',
|
||||||
'bluetooth-numbers',
|
'bluetooth-numbers',
|
||||||
'pybluez',
|
'pybluez @ https://github.com/pybluez/pybluez/tarball/master',
|
||||||
'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master',
|
'PyOBEX @ https://github.com/BlackLight/PyOBEX/tarball/master',
|
||||||
|
'TheengsGateway @ https://github.com/theengs/gateway/tarball/development',
|
||||||
],
|
],
|
||||||
# Support for TP-Link devices
|
# Support for TP-Link devices
|
||||||
'tplink': ['pyHS100'],
|
'tplink': ['pyHS100'],
|
||||||
|
|
Loading…
Reference in a new issue