Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updates to CoreBluetooth backend #209

Closed
wants to merge 32 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8de1b01
Updated scan callback (unused ret value)
bsiever May 3, 2020
b499a75
Multi Client: v1
bsiever May 3, 2020
2f0e93f
Added disconnect callback
bsiever May 3, 2020
110c024
Updated callback (no longer an async)
bsiever May 3, 2020
d67e997
Updated for context manager
bsiever May 5, 2020
2c015bd
Updated aexit (ensure correct order)
bsiever May 5, 2020
38c4fef
Initial attempt at scanner support.
bsiever May 6, 2020
e5e3470
Updated scanner (with is_scanning)
bsiever May 6, 2020
4e558fe
Update to scanner stuff..
bsiever May 13, 2020
69e1da8
Per-merge commits.
bsiever May 18, 2020
b399ecb
Updates for scanner filtering.
bsiever May 19, 2020
d9dce83
Discovery filter by UUIDs seems ok
bsiever May 20, 2020
3b95bbc
Initial connection test running!
bsiever May 24, 2020
b62e3ed
Basic unit tests.
bsiever May 26, 2020
f8ff65b
Updates for unit testing.
bsiever May 26, 2020
252b8df
Updated write with reply error test
bsiever May 27, 2020
b627d56
Updated error handling following writes.
bsiever May 27, 2020
8103657
Unit test updates.
bsiever May 27, 2020
790f61b
Updated tests.
bsiever May 28, 2020
954c299
Tests for notify and indicate
bsiever May 28, 2020
501fec8
Testing descriptors and indicate/notify.
bsiever May 29, 2020
e5c606c
Tests & updates to PeriphDelegate for failed reads
bsiever May 29, 2020
0475ac7
Updated to push firmware to Microbit
bsiever May 29, 2020
3da9655
Updated error handling and descriptor errors.
bsiever May 30, 2020
7bf991f
Comments to tests and context (closing at end)
bsiever May 30, 2020
d540735
Added --nofw to avoid re-upping firmware when not needed
bsiever May 30, 2020
18fe0d9
Scanning tests and fixes.
bsiever May 30, 2020
e5dc3e7
Updated test case.
bsiever May 30, 2020
cc2daaf
Merge branch 'scan-filter' into develop
bsiever May 30, 2020
39bd473
Use CoreBluetooth constant rather than hardcoded.
bsiever Jun 2, 2020
0120e84
Merge remote-tracking branch 'upstream/develop' into develop
bsiever Jun 2, 2020
38e7ec5
Updated error handling and waiting.
bsiever Jun 2, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 151 additions & 73 deletions bleak/backends/corebluetooth/CentralManagerDelegate.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""
CentralManagerDelegate will implement the CBCentralManagerDelegate protocol to
manage CoreBluetooth serivces and resources on the Central End
manage CoreBluetooth services and resources on the Central End

Created on June, 25 2019 by kevincar <kevincarrolldavis@gmail.com>

"""

import asyncio
import logging
import weakref
from enum import Enum
from typing import List

Expand All @@ -23,9 +24,23 @@
NSError,
)

from bleak.backends.corebluetooth.PeripheralDelegate import PeripheralDelegate
from bleak.backends.corebluetooth.device import BLEDeviceCoreBluetooth
from CoreBluetooth import (
CBManagerStatePoweredOff,
CBManagerStatePoweredOn,
CBManagerStateResetting,
CBManagerStateUnauthorized,
CBManagerStateUnknown,
CBManagerStateUnsupported,
CBCentralManagerScanOptionAllowDuplicatesKey
)

from bleak.backends.corebluetooth.device import BLEDeviceCoreBluetooth
from time import time
# Problem: Two functions reference the client (BleakClientCoreBluetooth).
# to get type info, they'd have to be imported. But this file is imported from the package
# and the client imports the CBAPP from the pacakge...So this leads to a circuar
# import
#import bleak.backends.corebluetooth.client.BleakClientCoreBluetooth as BleakClientCoreBluetooth

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -54,11 +69,12 @@ def init(self):
self, None
)

self.connected_peripheral_delegate = None
self.connected_peripheral = None
self._connection_state = CMDConnectionState.DISCONNECTED

self.ready = False
self._ready = False
# Dictionary of Addresses -> Clients
self._clients = weakref.WeakValueDictionary()
# scanner (did discover) callback
self._discovercb = None
self._filters = None
self.devices = {}

self.disconnected_callback = None
Expand All @@ -69,97 +85,128 @@ def init(self):
return self

# User defined functions
def setdiscovercallback_(self, callback):
if callback!=None:
self._discovercb = weakref.WeakMethod(callback)
else:
self._discovercb = None


# User defined functions
def removeclient_(self, client):
if client.address in self._clients:
otherClient = self._clients[client.address]
if id(otherClient) == id(client):
del self._clients[client.address]

def compliant(self):
"""Determins whether the class adheres to the CBCentralManagerDelegate protocol"""
"""Determines whether the class adheres to the CBCentralManagerDelegate protocol"""
return CentralManagerDelegate.pyobjc_classMethods.conformsToProtocol_(
CBCentralManagerDelegate
)

@property
def enabled(self):
"""Check if the bluetooth device is on and running"""
return self.central_manager.state() == 5

@property
def isConnected(self) -> bool:
return self._connection_state == CMDConnectionState.CONNECTED
return self.central_manager.state() not in [CBManagerStateUnsupported, CBManagerStateUnauthorized]

async def is_ready(self):
"""is_ready allows an asynchronous way to wait and ensure the
CentralManager has processed it's inputs before moving on"""
while not self.ready:
while not self._ready:
await asyncio.sleep(0)
return self.ready
return self._ready

async def scanForPeripherals_(self, scan_options) -> List[CBPeripheral]:
async def scanForPeripherals_(self, options):
"""
Scan for peripheral devices
scan_options = { service_uuids, timeout }

options dictionary contains one required and one optional value:

timeout is required
If a number, the time in seconds to scan before returning results
If None, then continuously scan (scan starts and must be stopped explicitly)

filters is optional as are individual keys in filters
Follows the filtering key/values used in BlueZ
(https://github.com/RadiusNetworks/bluez/blob/master/doc/adapter-api.txt)
filters :{
"DuplicateData": Are duplicate records allowed (default: True)
"UUIDs": [ Array of String UUIDs for services of interest. Any device that advertised one of them is included]
"RSSI" : only include devices with a greater RSSI.
"Pathloss": int minimum path loss; Only include devices that include TX power and where
TX power - RSSI > Pathloss value
}
"""
# remove old
logger.debug("Scanning...")
# remove old devices
self.devices = {}

# Scanning options cover service UUID filtering and removing duplicates
# Device discovery will cover RSSI & Pathloss limits
# Determine filtering data (used to start scan and validate detected devices)
self._filters = options.get("filters",{})
allow_duplicates = 1 if self._filters.get("DuplicateData", False) == True else 0

service_uuids = []
if "service_uuids" in scan_options:
service_uuids_str = scan_options["service_uuids"]
if "UUIDs" in self._filters:
service_uuids_str = self._filters["UUIDs"]
service_uuids = NSArray.alloc().initWithArray_(
list(map(string2uuid, service_uuids_str))
)

timeout = 0
if "timeout" in scan_options:
timeout = float(scan_options["timeout"])

self.central_manager.scanForPeripheralsWithServices_options_(
service_uuids, None
service_uuids, NSDictionary.dictionaryWithDictionary_({CBCentralManagerScanOptionAllowDuplicatesKey:allow_duplicates})
# service_uuids, {CBCentralManagerScanOptionAllowDuplicatesKey:allow_duplicates}
)

if timeout > 0:
await asyncio.sleep(timeout)

self.central_manager.stopScan()
while self.central_manager.isScanning():
await asyncio.sleep(0.1)

return []

async def connect_(self, peripheral: CBPeripheral) -> bool:
self._connection_state = CMDConnectionState.PENDING
self.central_manager.connectPeripheral_options_(peripheral, None)

while self._connection_state == CMDConnectionState.PENDING:
# service_uuids, NSDictionary.dictionaryWithDictionary_({CBCentralManagerScanOptionAllowDuplicatesKey:allow_duplicates})

timeout = options["timeout"]
if timeout is not None:
await asyncio.sleep(float(timeout))
# Request scan stop and wait for confirmation that it's done
self.central_manager.stopScan()
while self.central_manager.isScanning():
await asyncio.sleep(0.05)

async def connect_(self, client) -> bool:
client._connection_state = CMDConnectionState.PENDING
# Add client to map (before connect)
self._clients[client.address] = client
self.central_manager.connectPeripheral_options_(client._peripheral, None)

start = time()
while client._connection_state == CMDConnectionState.PENDING and time()-start<client._timeout:
await asyncio.sleep(0)

self.connected_peripheral = peripheral
return client._connection_state == CMDConnectionState.CONNECTED

return self._connection_state == CMDConnectionState.CONNECTED
async def disconnect_(self, client) -> bool:
client._connection_state = CMDConnectionState.PENDING
self.central_manager.cancelPeripheralConnection_(client._peripheral)

async def disconnect(self) -> bool:
self._connection_state = CMDConnectionState.PENDING
self.central_manager.cancelPeripheralConnection_(self.connected_peripheral)

while self._connection_state == CMDConnectionState.PENDING:
while client._connection_state == CMDConnectionState.PENDING:
await asyncio.sleep(0)

return self._connection_state == CMDConnectionState.DISCONNECTED
return client._connection_state == CMDConnectionState.DISCONNECTED

# Protocol Functions

def centralManagerDidUpdateState_(self, centralManager):
if centralManager.state() == 0:
def centralManagerDidUpdateState_(self, centralManager):
self._ready = False
if centralManager.state() == CBManagerStateUnknown:
logger.debug("Cannot detect bluetooth device")
elif centralManager.state() == 1:
elif centralManager.state() == CBManagerStateResetting:
logger.debug("Bluetooth is resetting")
elif centralManager.state() == 2:
elif centralManager.state() == CBManagerStateUnsupported:
logger.debug("Bluetooth is unsupported")
elif centralManager.state() == 3:
elif centralManager.state() == CBManagerStateUnauthorized:
logger.debug("Bluetooth is unauthorized")
elif centralManager.state() == 4:
elif centralManager.state() == CBManagerStatePoweredOff:
logger.debug("Bluetooth powered off")
elif centralManager.state() == 5:
elif centralManager.state() == CBManagerStatePoweredOn:
logger.debug("Bluetooth powered on")

self.ready = True
self._ready = True

def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(
self,
Expand All @@ -181,6 +228,25 @@ def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(
# CBCentralManagerScanOptionAllowDuplicatesKey global setting.

uuid_string = peripheral.identifier().UUIDString()
logger.debug("Discovered device {}: {} @ RSSI: {} (kCBAdvData {})".format(
uuid_string, peripheral.name() or None, RSSI, advertisementData.keys()))

# Filtering
min_rssi = self._filters.get("RSSI", None)
max_pathloss = self._filters.get("Pathloss", None)

rssi = float(RSSI)
if min_rssi is not None and rssi<min_rssi:
logger.debug("Device doesn't meet minimum RSSI ({} < {})".format(rssi, min_rssi))
return
# Compute path loss if there's a TX

if "CBAdvertisementDataTxPowerLevelKey" in advertisementData:
tx_power_level = float(advertisementData["CBAdvertisementDataTxPowerLevelKey"])
pathloss = tx_power_level - rssi
if pathloss > max_pathloss:
logger.debug("Device pathloss too great (tx ({}) - rssi ({}) > {})".format(tx_power_level, rssi, pathloss))
return

if uuid_string in self.devices:
device = self.devices[uuid_string]
Expand All @@ -194,40 +260,52 @@ def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(
device._rssi = float(RSSI)
device._update(advertisementData)

logger.debug("Discovered device {}: {} @ RSSI: {} (kCBAdvData {})".format(
uuid_string, device.name, RSSI, advertisementData.keys()))
# This is where a scanner callback should happen.
logger.warning("calling discovery callback with: {0}".format(device))
if self._discovercb != None:
(self._discovercb())(device)

def centralManager_didConnectPeripheral_(self, central, peripheral):
address = peripheral.identifier().UUIDString()
logger.debug(
"Successfully connected to device uuid {}".format(
peripheral.identifier().UUIDString()
address
)
)
peripheralDelegate = PeripheralDelegate.alloc().initWithPeripheral_(peripheral)
self.connected_peripheral_delegate = peripheralDelegate
self._connection_state = CMDConnectionState.CONNECTED
# If there's a client, update it
if address in self._clients:
client = self._clients[address]
client._connection_state = CMDConnectionState.CONNECTED

def centralManager_didFailToConnectPeripheral_error_(
self, centralManager: CBCentralManager, peripheral: CBPeripheral, error: NSError
):
address = peripheral.identifier().UUIDString()
logger.debug(
"Failed to connect to device uuid {}".format(
peripheral.identifier().UUIDString()
address
)
)
self._connection_state = CMDConnectionState.DISCONNECTED
# If there's a client, update it
if address in self._clients:
client = self._clients[address]
client._connection_state = CMDConnectionState.DISCONNECTED

def centralManager_didDisconnectPeripheral_error_(
self, central: CBCentralManager, peripheral: CBPeripheral, error: NSError
):
logger.debug("Peripheral Device disconnected!")
self._connection_state = CMDConnectionState.DISCONNECTED

if self.disconnected_callback is not None:
self.disconnected_callback()

address = peripheral.identifier().UUIDString()
logger.debug(
"Peripheral Device disconnected! {}".format(
address
)
)
# If there's a client, update it
if address in self._clients:
client = self._clients[address]
client._connection_state = CMDConnectionState.DISCONNECTED
client.did_disconnect()

def string2uuid(uuid_str: str) -> CBUUID:
"""Convert a string to a uuid"""
return CBUUID.UUIDWithString_(uuid_str)

Loading