| # |
| # Copyright (c) 2015-2017 Nest Labs, Inc. |
| # All rights reserved. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| # |
| # @file |
| # BLE Central support for Weave Device Manager via OSX CoreBluetooth APIs. |
| # |
| |
| import abc |
| import logging |
| import select |
| import socket |
| import sys |
| import Queue |
| import subprocess |
| import threading |
| import time |
| import binascii |
| from ctypes import * |
| import readline |
| import shlex |
| import optparse |
| from optparse import OptionParser, Option, OptionValueError |
| |
| from Foundation import * |
| import objc |
| from PyObjCTools import AppHelper |
| |
| from WeaveBleUtility import * |
| from WeaveBleUtility import _VoidPtrToByteArray |
| |
| from WeaveBleBase import WeaveBleBase |
| |
| |
| objc.loadBundle("CoreBluetooth", globals(), |
| bundle_path=objc.pathForFramework(u'/System/Library/Frameworks/IOBluetooth.framework/Versions/A/Frameworks/CoreBluetooth.framework')) |
| |
| weave_service = CBUUID.UUIDWithString_(u'0000FEAF-0000-1000-8000-00805F9B34FB') |
| weave_service_short = CBUUID.UUIDWithString_(u'FEAF') |
| weave_tx = CBUUID.UUIDWithString_(u'18EE2EF5-263D-4559-959F-4F9C429F9D11') |
| weave_rx = CBUUID.UUIDWithString_(u'18EE2EF5-263D-4559-959F-4F9C429F9D12') |
| |
| |
| def _VoidPtrToCBUUID(ptr, len): |
| try: |
| ptr = _VoidPtrToByteArray(ptr, len) |
| ptr = binascii.hexlify(ptr) |
| ptr = ptr[:8] + '-' + ptr[8:12] + '-' + ptr[12:16] + '-' + ptr[16:20] + '-' + ptr[20:] |
| ptr = CBUUID.UUIDWithString_(ptr) |
| except: |
| print "ERROR: failed to convert void * to CBUUID" |
| ptr = None |
| |
| return ptr |
| |
| |
| class CoreBluetoothManager(WeaveBleBase): |
| def __init__(self, devMgr, logger=None): |
| if logger: |
| self.logger = logger |
| else: |
| self.logger = logging.getLogger('WeaveBLEMgr') |
| logging.basicConfig( |
| level=logging.INFO, |
| format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s') |
| self.manager = None |
| self.peripheral = None |
| self.service = None |
| self.scan_quiet = False |
| self.characteristics = {} |
| self.peripheral_list = [] |
| self.bg_peripheral_name = None |
| self.weave_queue = Queue.Queue() |
| |
| self.manager = CBCentralManager.alloc() |
| self.manager.initWithDelegate_queue_options_(self, None, None) |
| |
| self.ready_condition = False |
| self.loop_condition = False # indicates whether the cmd requirement has been met in the runloop. |
| self.connect_state = False # reflects whether or not there is a connection. |
| self.send_condition = False |
| self.subscribe_condition = False |
| |
| self.runLoopUntil(("ready", time.time(), 10.0)) |
| |
| self.orig_input_hook = None |
| self.hookFuncPtr = None |
| |
| self.setInputHook(self.readlineCB) |
| self.devMgr = devMgr |
| self.devMgr.SetBlockingCB(self.devMgrCB) |
| |
| def HandleBleEventCB(): |
| return self.GetBleEvent() |
| |
| def HandleBleWriteCharCB(connObj, svcId, charId, buffer, length): |
| return self.WriteBleCharacteristic(connObj, svcId, charId, buffer, length) |
| |
| def HandleBleSubscribeCB(connObj, svcId, charId, subscribe): |
| return self.SubscribeBleCharacteristic(connObj, svcId, charId, subscribe) |
| |
| def HandleBleCloseCB(connObj): |
| return self.CloseBle(connObj) |
| |
| self.devMgr.SetBleEventCB(HandleBleEventCB) |
| self.devMgr.SetBleWriteCharCB(HandleBleWriteCharCB) |
| self.devMgr.SetBleSubscribeCharCB(HandleBleSubscribeCB) |
| self.devMgr.SetBleCloseCB(HandleBleCloseCB) |
| |
| # test if any connections currently exist (left around from a previous run) and disconnect if need be. |
| peripherals = self.manager.retrieveConnectedPeripheralsWithServices_([weave_service_short, weave_service]) |
| |
| if peripherals and len(peripherals): |
| for periph in peripherals: |
| self.logger.info("disconnecting old connection.") |
| self.loop_condition = False |
| self.manager.cancelPeripheralConnection_(periph) |
| self.runLoopUntil(("disconnect", time.time(), 5.0)) |
| |
| self.connect_state = False |
| self.loop_condition = False |
| |
| def __del__(self): |
| self.disconnect() |
| self.setInputHook(self.orig_input_hook) |
| self.devMgr.SetBlockingCB(None) |
| self.devMgr.SetBleEventCB(None) |
| |
| def devMgrCB(self): |
| """ A callback used by WeaveDeviceMgr.py to drive the OSX runloop while the |
| main thread waits for the Weave thread to complete its operation.""" |
| runLoop = NSRunLoop.currentRunLoop() |
| nextfire = runLoop.limitDateForMode_(NSDefaultRunLoopMode) |
| |
| def readlineCB(self): |
| """ A callback used by readline to drive the OSX runloop while the main thread |
| waits for commandline input from the user.""" |
| runLoop = NSRunLoop.currentRunLoop() |
| nextfire = runLoop.limitDateForMode_(NSDefaultRunLoopMode) |
| |
| if self.orig_input_hook: |
| self.orig_input_hook() |
| |
| def setInputHook(self, hookFunc): |
| """Set the PyOS_InputHook to call the specific function.""" |
| hookFunctionType = CFUNCTYPE(None) |
| self.hookFuncPtr = hookFunctionType(hookFunc) |
| pyos_inputhook_ptr = c_void_p.in_dll(pythonapi, "PyOS_InputHook") |
| # save the original so that on del we can revert it back to the way it was. |
| self.orig_input_hook = cast(pyos_inputhook_ptr.value, PYFUNCTYPE(c_int)) |
| # set the new hook. readLine will call this periodically as it polls for input. |
| pyos_inputhook_ptr.value = cast(self.hookFuncPtr, c_void_p).value |
| |
| def shouldLoop(self, should_tuple): |
| """ Used by runLoopUntil to determine whether it should exit the runloop.""" |
| result = False |
| |
| time_expired = time.time() >= should_tuple[1] + should_tuple[2] |
| |
| if should_tuple[0] == "ready": |
| if not self.ready_condition and not time_expired: |
| result = True |
| elif should_tuple[0] == "scan": |
| if not time_expired: |
| result = True |
| |
| for peripheral in self.peripheral_list: |
| if should_tuple[3] and str(peripheral._.name) == should_tuple[3]: |
| result = False |
| break |
| |
| elif should_tuple[0] == "connect": |
| if not self.loop_condition and not time_expired: |
| result = True |
| elif should_tuple[0] == "disconnect": |
| if not self.loop_condition and not time_expired: |
| result = True |
| elif should_tuple[0] == "send": |
| if not self.send_condition and not time_expired: |
| result = True |
| elif should_tuple[0] == "subscribe": |
| if not self.subscribe_condition and not time_expired: |
| result = True |
| elif should_tuple[0] == "unsubscribe": |
| if self.subscribe_condition and not time_expired: |
| result = True |
| |
| return result |
| |
| def runLoopUntil(self, should_tuple): |
| """ Helper function to drive OSX runloop until an expected event is received or |
| the timeout expires.""" |
| runLoop = NSRunLoop.currentRunLoop() |
| nextfire = 1 |
| |
| while nextfire and self.shouldLoop(should_tuple): |
| nextfire = runLoop.limitDateForMode_(NSDefaultRunLoopMode) |
| |
| def centralManagerDidUpdateState_(self, manager): |
| """ IO Bluetooth initialization is successful.""" |
| |
| state = manager.state() |
| string = "BLE is ready!" if state > 4 else "BLE is not ready!" |
| self.logger.info(string) |
| self.manager = manager |
| self.ready_condition = True if state > 4 else False |
| |
| def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(self, manager, peripheral, data, rssi): |
| """ Called for each peripheral discovered during scan.""" |
| if self.bg_peripheral_name is None: |
| if peripheral not in self.peripheral_list: |
| if not self.scan_quiet: |
| self.logger.info("adding to scan list:") |
| self.logger.info("") |
| self.logger.info("{0:<10}{1:<80}".format("Name =", str(peripheral._.name))) |
| self.logger.info("{0:<10}{1:<80}".format("ID =", str(peripheral._.identifier.UUIDString()))) |
| self.logger.info("{0:<10}{1:<80}".format("RSSI =", rssi)) |
| self.logger.info("ADV data: " + repr(data)) |
| self.logger.info("") |
| |
| self.peripheral_list.append(peripheral) |
| else: |
| if peripheral._.name == self.bg_peripheral_name: |
| if len(self.peripheral_list) == 0: |
| self.logger.info("found background peripheral") |
| self.peripheral_list = [peripheral] |
| |
| def centralManager_didConnectPeripheral_(self, manager, peripheral): |
| """Called by CoreBluetooth via runloop when a connection succeeds.""" |
| self.logger.debug(repr(peripheral)) |
| # make this class the delegate for peripheral events. |
| self.peripheral.setDelegate_(self) |
| # invoke service discovery on the periph. |
| self.peripheral.discoverServices_([weave_service_short, weave_service]) |
| |
| def centralManager_didFailToConnectPeripheral_error_(self, manager, peripheral, error): |
| """Called by CoreBluetooth via runloop when a connection fails.""" |
| self.logger.info("Failed to connect error = " + repr(error)) |
| self.loop_condition = True |
| self.connect_state = False |
| |
| def centralManager_didDisconnectPeripheral_error_(self, manager, peripheral, error): |
| """Called by CoreBluetooth via runloop when a disconnect completes. error = None on success.""" |
| self.loop_condition = True |
| self.connect_state = False |
| if self.devMgr: |
| self.logger.info("BLE disconnected, error = " + repr(error)) |
| dcEvent = BleDisconnectEvent(BLE_ERROR_REMOTE_DEVICE_DISCONNECTED) |
| self.weave_queue.put(dcEvent) |
| self.devMgr.DriveBleIO() |
| |
| def peripheral_didDiscoverServices_(self, peripheral, services): |
| """Called by CoreBluetooth via runloop when peripheral services are discovered.""" |
| self.service = self.peripheral.services()[0] |
| self.characteristics[self.service.UUID()] = [] |
| # NOTE: currently limiting discovery to only the pair of Weave characteristics. |
| self.peripheral.discoverCharacteristics_forService_([weave_rx, weave_tx], self.service) |
| |
| def peripheral_didDiscoverCharacteristicsForService_error_(self, peripheral, service, error): |
| """Called by CoreBluetooth via runloop when a characteristic for a service is discovered.""" |
| self.logger.debug(repr(service)) |
| self.logger.debug(repr(error)) |
| |
| if not error: |
| self.characteristics[service.UUID()] = [] |
| |
| for characteristic in self.service.characteristics(): |
| self.characteristics[service.UUID()].append(characteristic) |
| |
| self.connect_state = True |
| |
| else: |
| self.logger.error("ERROR: failed to discover characteristics for service.") |
| self.connect_state = False |
| |
| self.loop_condition = True |
| |
| def peripheral_didWriteValueForCharacteristic_error_(self, peripheral, characteristic, error): |
| """ Called by CoreBluetooth via runloop when a write to characteristic |
| operation completes. error = None on success.""" |
| self.logger.debug("didWriteValue error = " + repr(error)) |
| self.send_condition = True |
| charId = bytearray(characteristic.UUID().data().bytes().tobytes()) |
| svcId = bytearray(weave_service.data().bytes().tobytes()) |
| |
| if self.devMgr: |
| txEvent = BleTxEvent(charId=charId, svcId=svcId, status=True if not error else False) |
| self.weave_queue.put(txEvent) |
| self.devMgr.DriveBleIO() |
| |
| def peripheral_didUpdateNotificationStateForCharacteristic_error_(self, peripheral, characteristic, error): |
| """ Called by CoreBluetooth via runloop when a subscribe for notification operation completes. |
| Error = None on success.""" |
| self.logger.debug("Receiving notifications") |
| charId = bytearray(characteristic.UUID().data().bytes().tobytes()) |
| svcId = bytearray(weave_service.data().bytes().tobytes()) |
| # look at error and send True/False on Success/Failure |
| success = True if not error else False |
| if characteristic.isNotifying(): |
| operation = BleSubscribeOperation_Subscribe |
| self.subscribe_condition = True |
| else: |
| operation = BleSubscribeOperation_Unsubscribe |
| self.subscribe_condition = False |
| |
| self.logger.debug("Operation = " + repr(operation)) |
| self.logger.debug("success = " + repr(success)) |
| |
| if self.devMgr: |
| subscribeEvent = BleSubscribeEvent(charId=charId, svcId=svcId, status=success, operation=operation) |
| self.weave_queue.put(subscribeEvent) |
| self.devMgr.DriveBleIO() |
| |
| def peripheral_didUpdateValueForCharacteristic_error_(self, peripheral, characteristic, error): |
| """ Called by CoreBluetooth via runloop when a new characteristic value is received for a |
| characteristic to which this device has subscribed.""" |
| #len = characteristic.value().length() |
| bytes = bytearray(characteristic.value().bytes().tobytes()) |
| charId = bytearray(characteristic.UUID().data().bytes().tobytes()) |
| svcId = bytearray(weave_service.data().bytes().tobytes()) |
| |
| # Kick Weave thread to retrieve the saved packet. |
| if self.devMgr: |
| # Save buffer, length, service UUID and characteristic UUID |
| rxEvent = BleRxEvent(charId=charId, svcId=svcId, buffer=bytes) |
| self.weave_queue.put(rxEvent) |
| self.devMgr.DriveBleIO() |
| |
| self.logger.debug("received") |
| self.logger.debug("received (" + str(len) + ") bytes: " + repr(characteristic.value().bytes().tobytes())) |
| |
| def GetBleEvent(self): |
| """ Called by WeaveDeviceMgr.py on behalf of Weave to retrieve a queued message.""" |
| if not self.weave_queue.empty(): |
| ev = self.weave_queue.get() |
| |
| if isinstance(ev, BleRxEvent): |
| eventStruct = BleRxEventStruct.fromBleRxEvent(ev) |
| return cast( pointer(eventStruct), c_void_p).value |
| elif isinstance(ev, BleTxEvent): |
| eventStruct = BleTxEventStruct.fromBleTxEvent(ev) |
| return cast( pointer(eventStruct), c_void_p).value |
| elif isinstance(ev, BleSubscribeEvent): |
| eventStruct = BleSubscribeEventStruct.fromBleSubscribeEvent(ev) |
| return cast( pointer(eventStruct), c_void_p).value |
| elif isinstance(ev, BleDisconnectEvent): |
| eventStruct = BleDisconnectEventStruct.fromBleDisconnectEvent(ev) |
| return cast( pointer(eventStruct), c_void_p).value |
| |
| return None |
| |
| def Usage(self, cmd): |
| line = "USAGE: " |
| |
| if cmd == "scan": |
| line += "ble-scan [-t <timeout>] [<name>|<identifier>] [-q <quiet>]" |
| elif cmd == "scan-connect": |
| line += "ble-scan-connect [-t <timeout>] <name> [-q <quiet>]" |
| |
| self.logger.info(line) |
| |
| def ParseInputLine(self, line, cmd): |
| if cmd == "scan" or cmd == "scan-connect": |
| args = shlex.split(line) |
| |
| optParser = OptionParser(usage=optparse.SUPPRESS_USAGE) |
| optParser.add_option("-t", "--timeout", action="store", dest="timeout", type="float", default=10.0) |
| optParser.add_option("-q", "--quiet", action="store_true", dest="quiet") |
| try: |
| (options, remainingArgs) = optParser.parse_args(args) |
| except SystemExit: |
| self.Usage(cmd) |
| return None |
| |
| if len(remainingArgs) > 1: |
| self.Usage(cmd) |
| return None |
| |
| name = None |
| |
| if len(remainingArgs): |
| name = str(remainingArgs[0]) |
| elif cmd == "scan-connect": |
| self.Usage(cmd) |
| return None |
| |
| return (options.timeout, options.quiet, name) |
| |
| return None |
| |
| def scan(self, line): |
| """ API to initiatae BLE scanning for -t user_timeout seconds.""" |
| |
| args = self.ParseInputLine(line, "scan") |
| |
| if not args: |
| return |
| self.scan_quiet = args[1] |
| self.bg_peripheral_name = None |
| del self.peripheral_list[:] |
| self.peripheral_list = [] |
| # Filter on the service UUID Array or None to accept all scan results. |
| self.manager.scanForPeripheralsWithServices_options_([weave_service_short, weave_service], None) |
| #self.manager.scanForPeripheralsWithServices_options_(None, None) |
| |
| self.runLoopUntil(("scan", time.time(), args[0], args[2])) |
| |
| self.manager.stopScan() |
| self.logger.info("scanning stopped") |
| |
| def bgScanStart(self, name): |
| """ API to initiate background BLE scanning.""" |
| self.logger.info("scanning started") |
| self.bg_peripheral_name = name |
| del self.peripheral_list[:] |
| self.peripheral_list = [] |
| # Filter on the service UUID Array or None to accept all scan results. |
| self.manager.scanForPeripheralsWithServices_options_([weave_service_short, weave_service], None) |
| |
| def bgScanStop(self): |
| """ API to stop background BLE scanning.""" |
| self.manager.stopScan() |
| self.bg_peripheral_name = None |
| self.logger.info("scanning stopped") |
| |
| def connect(self, identifier): |
| """ API to initiate BLE connection to peripheral device whose identifier == identifier.""" |
| self.logger.info("trying to connect to " + identifier) |
| |
| if self.connect_state: |
| self.logger.error("ERROR: Connection to a BLE device already exists!") |
| else: |
| for p in self.peripheral_list: |
| p_id = str(p.identifier().UUIDString()) |
| p_name = str(p.name()) |
| |
| self.logger.debug(p_id + " vs " + str(identifier)) |
| self.logger.debug(p_name + " vs " + str(identifier)) |
| |
| if p_id == str(identifier) or p_name == str(identifier): |
| self.loop_condition = False |
| self.peripheral = p |
| self.manager.connectPeripheral_options_(p, None) |
| |
| self.runLoopUntil(("connect", time.time(), 15.0)) |
| # Cleanup when the connect fails due to timeout, |
| # otherwise CoreBluetooth will continue to try to connect after this |
| # API exits. |
| if not self.connect_state: |
| self.manager.cancelPeripheralConnection_(p) |
| self.peripheral = None |
| |
| break |
| |
| ret = True if self.loop_condition and self.connect_state else False |
| |
| resString = "connect " + ("success" if ret else "fail") |
| self.logger.info(resString) |
| |
| return ret |
| |
| def disconnect(self): |
| """ API to initiate BLE disconnect procedure.""" |
| self.logger.info("disconnecting") |
| |
| if self.peripheral and self.peripheral.state() != BlePeripheralState_Disconnected: |
| self.loop_condition = False |
| self.manager.cancelPeripheralConnection_(self.peripheral) |
| |
| self.runLoopUntil(("disconnect", time.time(), 10.0)) |
| |
| resString = "disconnect " + ("success" if self.loop_condition and not self.connect_state else "fail") |
| self.logger.info(resString) |
| |
| self.characteristics = {} |
| #del self.peripheral_list[:] |
| #self.peripheral_list = [] |
| self.peripheral = None |
| self.service = None |
| |
| def scan_connect(self, line): |
| """ API to perform both scan and connect operations in one call.""" |
| |
| args = self.ParseInputLine(line, "scan-connect") |
| |
| if not args: |
| return |
| |
| self.scan_quiet = args[1] |
| self.scan(line) |
| |
| if len(self.peripheral_list): |
| return self.connect(args[2]) |
| else: |
| self.logger.info("Failed to scan device named: " + args[2] + ". Connection skipped.") |
| return False |
| |
| |
| def isConnected(self): |
| if self.peripheral and self.peripheral.state() != BlePeripheralState_Disconnected: |
| return True |
| |
| return False |
| |
| def WriteBleCharacteristic(self, connObj, svcId, charId, buffer, length): |
| """ Called by WeaveDeviceMgr.py to satisfy a request by Weave to transmit a packet over BLE.""" |
| result = False |
| |
| bytes = _VoidPtrToByteArray(buffer, length) |
| bytes = NSData.dataWithBytes_length_(bytes, len(bytes)) # convert bytearray to NSData |
| |
| svcId = _VoidPtrToCBUUID(svcId, 16) |
| charId = _VoidPtrToCBUUID(charId, 16) |
| |
| if self.peripheral and self.peripheral.state() != BlePeripheralState_Disconnected: |
| for char in self.characteristics[svcId]: |
| if char.UUID() == charId: |
| self.peripheral.writeValue_forCharacteristic_type_(bytes, char, CBCharacteristicWriteWithResponse) |
| result = True |
| break |
| else: |
| self.logger.warning("WARNING: peripheral is no longer connected.") |
| |
| return result |
| |
| def SubscribeBleCharacteristic(self, connObj, svcId, charId, subscribe): |
| """ Called by Weave to (un-)subscribe to a characteristic of a service.""" |
| result = False |
| |
| svcId = _VoidPtrToCBUUID(svcId, 16) |
| charId = _VoidPtrToCBUUID(charId, 16) |
| |
| if self.peripheral and self.peripheral.state() != BlePeripheralState_Disconnected: |
| for char in self.characteristics[svcId]: |
| if char.UUID() == charId: |
| self.peripheral.setNotifyValue_forCharacteristic_(True if subscribe else False, char) |
| result = True |
| break |
| else: |
| self.logger.warning("WARNING: peripheral is no longer connected.") |
| |
| return result |
| |
| def ble_debug_log(self, line): |
| args = self.ParseInputLine(line) |
| if int(args[0]) == 1: |
| self.logger.setLevel(logging.DEBUG) |
| self.logger.debug("current logging level is debug") |
| else: |
| self.logger.setLevel(logging.INFO) |
| self.logger.debug("current logging level is info") |
| return True |
| |
| def CloseBle(self, connObj): |
| """ Called by Weave to close the BLE connection.""" |
| if self.peripheral: |
| self.manager.cancelPeripheralConnection_(self.peripheral) |
| self.characteristics = {} |
| #del self.peripheral_list[:] |
| #self.peripheral_list = [] |
| self.peripheral = None |
| self.service = None |
| self.connect_state = False |
| |
| return True |
| |
| def updateCharacteristic(self, bytes, svcId, charId): |
| # TODO: implement this for Peripheral support. |
| return False |