Extended number of supported events and data fields in Bluetooth integration.

This commit is contained in:
Fabio Manganiello 2023-02-18 01:15:10 +01:00
parent 7adae272a4
commit 613e32e7c1
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
7 changed files with 301 additions and 177 deletions

View File

@ -297,6 +297,7 @@ autodoc_mock_imports = [
'aiofiles.os', 'aiofiles.os',
'async_lru', 'async_lru',
'bleak', 'bleak',
'bluetooth_numbers',
] ]
sys.path.insert(0, os.path.abspath('../..')) sys.path.insert(0, os.path.abspath('../..'))

View File

@ -1,4 +1,10 @@
from sqlalchemy import Column, Integer, Boolean, ForeignKey from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Integer,
JSON,
)
from platypush.common.db import Base from platypush.common.db import Base
@ -17,7 +23,46 @@ if 'bluetooth_device' not in Base.metadata:
id = Column( id = Column(
Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True
) )
connected = Column(Boolean, default=False) 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.
"""
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__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,

View File

@ -1,4 +1,4 @@
from typing import Optional from typing import Dict, Optional
from platypush.message.event import Event from platypush.message.event import Event
@ -8,44 +8,162 @@ class BluetoothEvent(Event):
Base class for Bluetooth events. Base class for Bluetooth events.
""" """
def __init__(self, address: str, *args, name: Optional[str] = None, **kwargs):
super().__init__(*args, address=address, name=name, **kwargs)
class BluetoothScanPausedEvent(BluetoothEvent):
class BluetoothWithPortEvent(BluetoothEvent):
""" """
Base class for Bluetooth events that include a communication port. Event triggered when the Bluetooth scan is paused.
"""
def __init__(self, *args, duration: Optional[float] = None, **kwargs):
super().__init__(*args, duration=duration, **kwargs)
class BluetoothScanResumedEvent(BluetoothEvent):
"""
Event triggered when the Bluetooth scan is resumed.
"""
def __init__(self, *args, duration: Optional[float] = None, **kwargs):
super().__init__(*args, duration=duration, **kwargs)
class BluetoothWithPortEvent(Event):
"""
Base class for Bluetooth events with an associated port.
""" """
def __init__(self, *args, port: Optional[str] = None, **kwargs): def __init__(self, *args, port: Optional[str] = None, **kwargs):
"""
:param port: The communication port of the device.
"""
super().__init__(*args, port=port, **kwargs) super().__init__(*args, port=port, **kwargs)
class BluetoothDeviceFoundEvent(BluetoothEvent): class BluetoothDeviceEvent(BluetoothWithPortEvent):
""" """
Event triggered when a Bluetooth device is found during a scan. Base class for Bluetooth device events.
"""
def __init__(
self,
*args,
address: str,
connected: bool,
paired: bool,
trusted: bool,
blocked: bool,
name: Optional[str] = None,
uuids: Optional[Dict[str, str]] = None,
rssi: Optional[int] = None,
tx_power: Optional[int] = None,
manufacturers: Optional[Dict[int, str]] = None,
manufacturer_data: Optional[Dict[int, str]] = None,
service_data: Optional[Dict[str, str]] = None,
**kwargs
):
"""
:param address: The Bluetooth address of the device.
:param connected: Whether the device is connected.
:param paired: Whether the device is paired.
:param trusted: Whether the device is trusted.
:param blocked: Whether the device is blocked.
:param name: The name of the device.
:param uuids: The UUIDs of the services exposed by the device.
:param rssi: Received Signal Strength Indicator.
:param tx_power: Transmission power.
:param manufacturers: The manufacturers published by the device, as a
``manufacturer_id -> registered_name`` map.
:param manufacturer_data: The manufacturer data published by the
device, as a ``manufacturer_id -> data`` map, where ``data`` is a
hexadecimal string.
:param service_data: The service data published by the device, as a
``service_uuid -> data`` map, where ``data`` is a hexadecimal string.
"""
super().__init__(
*args,
address=address,
name=name,
connected=connected,
paired=paired,
blocked=blocked,
trusted=trusted,
uuids=uuids or {},
rssi=rssi,
tx_power=tx_power,
manufacturers=manufacturers or {},
manufacturer_data=manufacturer_data or {},
service_data=service_data or {},
**kwargs
)
class BluetoothDeviceFoundEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is discovered during a scan.
""" """
class BluetoothDeviceLostEvent(BluetoothEvent): class BluetoothDeviceLostEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device previously scanned is lost. Event triggered when a previously discovered Bluetooth device is lost.
""" """
class BluetoothDeviceConnectedEvent(BluetoothWithPortEvent): class BluetoothDeviceConnectedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is connected. Event triggered when a Bluetooth device is connected.
""" """
class BluetoothDeviceDisconnectedEvent(BluetoothWithPortEvent): class BluetoothDeviceDisconnectedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is disconnected. Event triggered when a Bluetooth device is disconnected.
""" """
class BluetoothConnectionRejectedEvent(BluetoothWithPortEvent): class BluetoothDevicePairedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is paired.
"""
class BluetoothDeviceUnpairedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is unpaired.
"""
class BluetoothDeviceBlockedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is blocked.
"""
class BluetoothDeviceUnblockedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is unblocked.
"""
class BluetoothDeviceTrustedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is trusted.
"""
class BluetoothDeviceSignalUpdateEvent(BluetoothDeviceEvent):
"""
Event triggered when the RSSI/TX power of a Bluetooth device is updated.
"""
class BluetoothDeviceUntrustedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is untrusted.
"""
class BluetoothConnectionRejectedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth connection is rejected. Event triggered when a Bluetooth connection is rejected.
""" """

View File

@ -1,130 +0,0 @@
from typing import Collection, Optional
from platypush.message.event import Event
class BluetoothEvent(Event):
"""
Base class for Bluetooth events.
"""
class BluetoothScanPausedEvent(BluetoothEvent):
"""
Event triggered when the Bluetooth scan is paused.
"""
def __init__(self, *args, duration: Optional[float] = None, **kwargs):
super().__init__(*args, duration=duration, **kwargs)
class BluetoothScanResumedEvent(BluetoothEvent):
"""
Event triggered when the Bluetooth scan is resumed.
"""
def __init__(self, *args, duration: Optional[float] = None, **kwargs):
super().__init__(*args, duration=duration, **kwargs)
class BluetoothDeviceEvent(BluetoothEvent):
"""
Base class for Bluetooth device events.
"""
def __init__(
self,
*args,
address: str,
connected: bool,
paired: bool,
trusted: bool,
blocked: bool,
name: Optional[str] = None,
characteristics: Optional[Collection[str]] = None,
**kwargs
):
"""
:param address: The Bluetooth address of the device.
:param connected: Whether the device is connected.
:param paired: Whether the device is paired.
:param trusted: Whether the device is trusted.
:param blocked: Whether the device is blocked.
:param name: The name of the device.
:param characteristics: The UUIDs of the characteristics exposed by the
device.
"""
super().__init__(
*args,
address=address,
name=name,
connected=connected,
paired=paired,
blocked=blocked,
trusted=trusted,
characteristics=characteristics or [],
**kwargs
)
class BluetoothDeviceFoundEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is discovered during a scan.
"""
class BluetoothDeviceLostEvent(BluetoothDeviceEvent):
"""
Event triggered when a previously discovered Bluetooth device is lost.
"""
class BluetoothDeviceConnectedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is connected.
"""
class BluetoothDeviceDisconnectedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is disconnected.
"""
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 BluetoothDeviceUntrustedEvent(BluetoothDeviceEvent):
"""
Event triggered when a Bluetooth device is untrusted.
"""
# vim:sw=4:ts=4:et:

View File

@ -2,23 +2,38 @@ import base64
from asyncio import Event, ensure_future from asyncio import Event, ensure_future
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from threading import RLock, Timer from threading import RLock, Timer
from typing import AsyncGenerator, Collection, List, Optional, Dict, Type, Union from time import time
from typing import (
Any,
AsyncGenerator,
Collection,
Final,
List,
Optional,
Dict,
Type,
Union,
)
from uuid import UUID from uuid import UUID
from bleak import BleakClient, BleakScanner from bleak import BleakClient, BleakScanner
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.uuids import uuidstr_to_str
from bluetooth_numbers import company
from typing_extensions import override from typing_extensions import override
from platypush.context import get_bus, get_or_create_event_loop from platypush.context import get_bus, get_or_create_event_loop
from platypush.entities import Entity, EntityManager from platypush.entities import Entity, EntityManager
from platypush.entities.bluetooth import BluetoothDevice from platypush.entities.bluetooth import BluetoothDevice
from platypush.message.event.bluetooth.ble import ( from platypush.message.event.bluetooth import (
BluetoothDeviceBlockedEvent, BluetoothDeviceBlockedEvent,
BluetoothDeviceConnectedEvent, BluetoothDeviceConnectedEvent,
BluetoothDeviceDisconnectedEvent, BluetoothDeviceDisconnectedEvent,
BluetoothDeviceFoundEvent, BluetoothDeviceFoundEvent,
BluetoothDeviceLostEvent, BluetoothDeviceLostEvent,
BluetoothDevicePairedEvent, BluetoothDevicePairedEvent,
BluetoothDeviceSignalUpdateEvent,
BluetoothDeviceTrustedEvent, BluetoothDeviceTrustedEvent,
BluetoothDeviceUnblockedEvent, BluetoothDeviceUnblockedEvent,
BluetoothDeviceUnpairedEvent, BluetoothDeviceUnpairedEvent,
@ -42,20 +57,40 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
Requires: Requires:
* **bleak** (``pip install bleak``) * **bleak** (``pip install bleak``)
* **bluetooth-numbers** (``pip install bluetooth-numbers``)
TODO: Write supported events. 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`
""" """
# Default connection timeout (in seconds) _default_connect_timeout: Final[int] = 5
_default_connect_timeout = 5 """ Default connection timeout (in seconds) """
_rssi_update_interval: Final[int] = 30
"""
How long we should wait before triggering an update event upon a new
RSSI update, in seconds.
"""
def __init__( def __init__(
self, self,
interface: Optional[str] = None, interface: Optional[str] = None,
connect_timeout: float = _default_connect_timeout, connect_timeout: float = _default_connect_timeout,
device_names: Optional[Dict[str, str]] = None, device_names: Optional[Dict[str, str]] = None,
characteristics: Optional[Collection[UUIDType]] = None, uuids: Optional[Collection[UUIDType]] = None,
scan_paused_on_start: bool = False, scan_paused_on_start: bool = False,
**kwargs, **kwargs,
): ):
@ -64,8 +99,8 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
on Linux). Default: first available interface. on Linux). Default: first available interface.
:param connect_timeout: Timeout in seconds for the connection to a :param connect_timeout: Timeout in seconds for the connection to a
Bluetooth device. Default: 5 seconds. Bluetooth device. Default: 5 seconds.
:param characteristics: List of service/characteristic UUIDs to :param uuids: List of service/characteristic UUIDs to discover.
discover. Default: all. Default: all.
:param device_names: Bluetooth address -> device name mapping. If not :param device_names: Bluetooth address -> device name mapping. If not
specified, the device's advertised name will be used, or its specified, the device's advertised name will be used, or its
Bluetooth address. Example: Bluetooth address. Example:
@ -85,14 +120,15 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self._interface = interface self._interface: Optional[str] = interface
self._connect_timeout = connect_timeout self._connect_timeout: float = connect_timeout
self._characteristics = characteristics self._uuids: Collection[Union[str, UUID]] = uuids or []
self._scan_lock = RLock() self._scan_lock = RLock()
self._scan_enabled = Event() self._scan_enabled = Event()
self._scan_controller_timer: Optional[Timer] = None self._scan_controller_timer: Optional[Timer] = None
self._connections: Dict[str, BleakClient] = {} self._connections: Dict[str, BleakClient] = {}
self._devices: Dict[str, BLEDevice] = {} self._devices: Dict[str, BLEDevice] = {}
self._device_last_updated_at: Dict[str, float] = {}
self._device_name_by_addr = device_names or {} self._device_name_by_addr = device_names or {}
self._device_addr_by_name = { self._device_addr_by_name = {
name: addr for addr, name in self._device_name_by_addr.items() name: addr for addr, name in self._device_name_by_addr.items()
@ -129,21 +165,54 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
def _post_event( def _post_event(
self, event_type: Type[BluetoothDeviceEvent], device: BLEDevice, **kwargs self, event_type: Type[BluetoothDeviceEvent], device: BLEDevice, **kwargs
): ):
props = device.details.get('props', {})
get_bus().post( get_bus().post(
event_type( event_type(
address=device.address, address=device.address, **self._parse_device_args(device), **kwargs
name=self._get_device_name(device),
connected=props.get('Connected', False),
paired=props.get('Paired', False),
blocked=props.get('Blocked', False),
trusted=props.get('Trusted', False),
characteristics=device.metadata.get('uuids', []),
**kwargs,
) )
) )
def _on_device_event(self, device: BLEDevice, _): def _parse_device_args(self, device: BLEDevice) -> Dict[str, Any]:
props = device.details.get('props', {})
return {
'name': self._get_device_name(device),
'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': self._parse_manufacturer_data(device),
'service_data': self._parse_service_data(device),
}
@staticmethod
def _parse_manufacturer_data(device: BLEDevice) -> Dict[int, str]:
return {
manufacturer_id: ':'.join([f'{x:02x}' for x in value])
for manufacturer_id, value in device.metadata.get(
'manufacturer_data', {}
).items()
}
@staticmethod
def _parse_service_data(device: BLEDevice) -> Dict[str, str]:
return {
service_uuid: ':'.join([f'{x:02x}' for x in value])
for service_uuid, value in device.details.get('props', {})
.get('ServiceData', {})
.items()
}
def _on_device_event(self, device: BLEDevice, _: AdvertisementData):
event_types: List[Type[BluetoothDeviceEvent]] = [] event_types: List[Type[BluetoothDeviceEvent]] = []
existing_device = self._devices.get(device.address) existing_device = self._devices.get(device.address)
@ -178,6 +247,15 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
if new_props.get('Trusted') if new_props.get('Trusted')
else BluetoothDeviceUntrustedEvent else BluetoothDeviceUntrustedEvent
) )
if (
time() - self._device_last_updated_at.get(device.address, 0)
) >= self._rssi_update_interval and (
existing_device.rssi != device.rssi
or old_props.get('TxPower') != new_props.get('TxPower')
):
event_types.append(BluetoothDeviceSignalUpdateEvent)
else: else:
event_types.append(BluetoothDeviceFoundEvent) event_types.append(BluetoothDeviceFoundEvent)
@ -190,6 +268,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
for event_type in event_types: for event_type in event_types:
self._post_event(event_type, device) self._post_event(event_type, device)
self.publish_entities([device]) self.publish_entities([device])
self._device_last_updated_at[device.address] = time()
@asynccontextmanager @asynccontextmanager
async def _connect( async def _connect(
@ -234,7 +313,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
async def _scan( async def _scan(
self, self,
duration: Optional[float] = None, duration: Optional[float] = None,
characteristics: Optional[Collection[UUIDType]] = None, uuids: Optional[Collection[UUIDType]] = None,
publish_entities: bool = False, publish_entities: bool = False,
) -> Collection[Entity]: ) -> Collection[Entity]:
with self._scan_lock: with self._scan_lock:
@ -242,14 +321,10 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
devices = await BleakScanner.discover( devices = await BleakScanner.discover(
adapter=self._interface, adapter=self._interface,
timeout=timeout, timeout=timeout,
service_uuids=list( service_uuids=list(map(str, uuids or self._uuids or [])),
map(str, characteristics or self._characteristics or [])
),
detection_callback=self._on_device_event, detection_callback=self._on_device_event,
) )
# TODO Infer type from device.metadata['manufacturer_data']
self._devices.update({dev.address: dev for dev in devices}) self._devices.update({dev.address: dev for dev in devices})
return ( return (
self.publish_entities(devices) self.publish_entities(devices)
@ -307,7 +382,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
def scan( def scan(
self, self,
duration: Optional[float] = None, duration: Optional[float] = None,
characteristics: Optional[Collection[UUIDType]] = None, uuids: Optional[Collection[UUIDType]] = None,
): ):
""" """
Scan for Bluetooth devices nearby and return the results as a list of Scan for Bluetooth devices nearby and return the results as a list of
@ -315,11 +390,11 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
:param duration: Scan duration in seconds (default: same as the plugin's :param duration: Scan duration in seconds (default: same as the plugin's
`poll_interval` configuration parameter) `poll_interval` configuration parameter)
:param characteristics: List of characteristic UUIDs to discover. Default: all. :param uuids: List of characteristic UUIDs to discover. Default: all.
""" """
loop = get_or_create_event_loop() loop = get_or_create_event_loop()
return loop.run_until_complete( return loop.run_until_complete(
self._scan(duration, characteristics, publish_entities=True) self._scan(duration, uuids, publish_entities=True)
) )
@action @action
@ -388,7 +463,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
return [ return [
BluetoothDevice( BluetoothDevice(
id=dev.address, id=dev.address,
name=self._get_device_name(dev), **self._parse_device_args(dev),
) )
for dev in entities for dev in entities
] ]

View File

@ -1,7 +1,20 @@
manifest: manifest:
events: {} 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.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: install:
pip: pip:
- bleak - bleak
- bluetooth-numbers
package: platypush.plugins.bluetooth.ble package: platypush.plugins.bluetooth.ble
type: plugin type: plugin

View File

@ -173,9 +173,11 @@ setup(
], ],
# Support for Alexa/Echo plugin # Support for Alexa/Echo plugin
'alexa': ['avs @ https://github.com/BlackLight/avs/tarball/master'], 'alexa': ['avs @ https://github.com/BlackLight/avs/tarball/master'],
# Support for bluetooth devices # Support for Bluetooth devices
'bluetooth': [ 'bluetooth': [
'bleak', 'bleak',
'bluetooth-numbers',
'pybluez',
'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master', 'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master',
], ],
# Support for TP-Link devices # Support for TP-Link devices