diff --git a/LICENSE b/LICENSE index 222a54a..f87b3e9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 The Cellule +Copyright (c) 2017 The Cellule Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.rst b/README.rst index 439a834..a0d2428 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,72 @@ ============= python-bleson -============= \ No newline at end of file +============= + +Status +====== + +Experimental. + + +Public API +========== + +Outline of the 'bleson' Python packages an end-user would use to play with Bluetooth LE. + +.. code:: python + + bleson + get_provider() # Obtain the provider for the current platfrom (currently Linux HCI only) + + bleson.core.types # Value objects representing Bluetooth Core Spec data types (not HCI specific) + BDAddress + Device + Advertisement + + bleson.core.roles # Core Bluetooth roles + Advertiser + Observer + + bleson.beacons.eddystone + EddystoneBeacon # Physical Web beacon, is a subclass of Advertiser role + + bleson.logger + log # Simple convenience wrapper around Python's standard logging. + + + +Examples +======== + +Please see 'examples' folder for more details. +Examples prefixed with 'basic_' shows basic Bleson API usage. +Examples prefixed with 'context_' shows Blesons context maanger ('with' keyword) API usage. + + +Example - Advertiser +-------------------- + +Shows how to create custom advertisement. + +Example - Eddystone Beacon +-------------------------- + +Shows how to setup a Physical Web beacon + +Example - Observer +------------------ + +Shows how to scan for local devices. + + +Tests +===== + +Please see the 'tests' folder. + + +Internal API +============ + +To be continued... + diff --git a/bleson/__init__.py b/bleson/__init__.py index e69de29..f22aa4f 100644 --- a/bleson/__init__.py +++ b/bleson/__init__.py @@ -0,0 +1,7 @@ +from .providers import get_provider +from .core.roles import * +from .core.types import * +from .beacons.eddystone import * +from .logger import set_level +from .core.hci.constants import * + diff --git a/bleson/beacons/__init__.py b/bleson/beacons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bleson/beacons/eddystone.py b/bleson/beacons/eddystone.py new file mode 100644 index 0000000..66696a7 --- /dev/null +++ b/bleson/beacons/eddystone.py @@ -0,0 +1,103 @@ +from bleson.core.roles import Advertiser +from bleson.core.types import Advertisement +from bleson.logger import log + + +class EddystoneBeacon(Advertiser): + + def __init__(self, adapter, url=None): + super().__init__(adapter) + self.advertisement=Advertisement() + self.url = url + + @property + def url(self): + return self._url + + @url.setter + def url(self, url): + self._url = url + if url: + self.advertisement.raw_data=self.eddystone_url_adv_data(url) + log.debug("Beacon Adv raw data = {}".format(self.advertisement.raw_data)) + + # ------------------------------------------- + # Eddystone (pretty much as-is from the Google source) + # see: https://github.com/google/eddystone/blob/master/eddystone-url/implementations/PyBeacon/PyBeacon/PyBeacon.py + + schemes = [ + "http://www.", + "https://www.", + "http://", + "https://", + ] + + extensions = [ + ".com/", ".org/", ".edu/", ".net/", ".info/", ".biz/", ".gov/", + ".com", ".org", ".edu", ".net", ".info", ".biz", ".gov", + ] + + @classmethod + def encode_url(cls, url): + i = 0 + data = [] + + for s in range(len(cls.schemes)): + scheme = cls.schemes[s] + if url.startswith(scheme): + data.append(s) + i += len(scheme) + break + else: + raise Exception("Invalid url scheme") + + while i < len(url): + if url[i] == '.': + for e in range(len(cls.extensions)): + expansion = cls.extensions[e] + if url.startswith(expansion, i): + data.append(e) + i += len(expansion) + break + else: + data.append(0x2E) + i += 1 + else: + data.append(ord(url[i])) + i += 1 + + return data + + @classmethod + def eddystone_url_adv_data(cls, url): + log.info("Encoding URL for Eddystone beacon: '{}'".format(url)) + encodedurl = cls.encode_url(url) + encodedurlLength = len(encodedurl) + + if encodedurlLength > 18: + raise ValueError("Encoded url length {} is > 18 maximum length.".format(encodedurlLength)) + + message = [ + 0x02, # Flags length + 0x01, # Flags data type value + 0x1a, # Flags data + + 0x03, # Service UUID length + 0x03, # Service UUID data type value + 0xaa, # 16-bit Eddystone UUID + 0xfe, # 16-bit Eddystone UUID + + 5 + len(encodedurl), # Service Data length + 0x16, # Service Data data type value + 0xaa, # 16-bit Eddystone UUID + 0xfe, # 16-bit Eddystone UUID + + 0x10, # Eddystone-url frame type + 0xed, # txpower + ] + + message += encodedurl + + return bytearray(message) + + diff --git a/bleson/core/__init__.py b/bleson/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bleson/core/hci/__init__.py b/bleson/core/hci/__init__.py new file mode 100644 index 0000000..24be18e --- /dev/null +++ b/bleson/core/hci/__init__.py @@ -0,0 +1,4 @@ +from .constants import * +from .types import * + + diff --git a/bleson/core/hci/constants.py b/bleson/core/hci/constants.py new file mode 100644 index 0000000..5cbfbdd --- /dev/null +++ b/bleson/core/hci/constants.py @@ -0,0 +1,245 @@ + +LE_PUBLIC_ADDRESS = 0x00 +LE_RANDOM_ADDRESS = 0x01 + +# Types of bluetooth scan + +SCAN_TYPE_PASSIVE = 0x00 +SCAN_TYPE_ACTIVE = 0x01 +SCAN_FILTER_DUPLICATES = 0x01 +SCAN_DISABLE = 0x00 +SCAN_ENABLE = 0x01 + + +# Advertisement event types +ADV_IND = 0x00 +ADV_DIRECT_IND = 0x01 +ADV_SCAN_IND = 0x02 +ADV_NONCONN_IND = 0x03 +ADV_SCAN_RSP = 0x04 + +FILTER_POLICY_NO_WHITELIST = 0x00 # Allow Scan Request from Any, Connect Request from Any +FILTER_POLICY_SCAN_WHITELIST = 0x01 # Allow Scan Request from White List Only, Connect Request from Any +FILTER_POLICY_CONN_WHITELIST = 0x02 # Allow Scan Request from Any, Connect Request from White List Only +FILTER_POLICY_SCAN_AND_CONN_WHITELIST = 0x03 # Allow Scan Request from White List Only, Connect Request from White List Only + + +EVT_CMD_COMPLETE = 0x0e +EVT_CMD_STATUS = 0x0f + + + +# sub-events of LE_META_EVENT +EVT_LE_CONN_COMPLETE = 0x01 + +EVT_LE_ADVERTISING_REPORT = 0x02 +EVT_LE_CONN_UPDATE_COMPLETE = 0x03 +EVT_LE_READ_REMOTE_USED_FEATURES_COMPLETE = 0x04 +EVT_DISCONN_COMPLETE = 0x05 +EVT_LE_META_EVENT = 0x3e # Core_4.2.pdf section: 7.7.65 LE Meta Event + + +ATT_CID = 0x0004 + + +ACL_START = 0x02 + + +OGF_LE_CTL = 0x08 +OGF_LINK_CTL = 0x01 + +OCF_LE_SET_SCAN_PARAMETERS = 0x000B +OCF_LE_SET_SCAN_ENABLE = 0x000C +OCF_LE_CREATE_CONN = 0x000D +OCF_LE_SET_ADVERTISING_PARAMETERS = 0x0006 +OCF_LE_SET_ADVERTISE_ENABLE = 0x000A +OCF_LE_SET_ADVERTISING_DATA = 0x0008 +OCF_LE_SET_SCAN_RESPONSE_DATA = 0x0009 +OCF_DISCONNECT = 0x0006 + +LE_SET_SCAN_PARAMETERS_CMD = OCF_LE_SET_SCAN_PARAMETERS | OGF_LE_CTL << 10 +LE_SET_SCAN_ENABLE_CMD = OCF_LE_SET_SCAN_ENABLE | OGF_LE_CTL << 10 +LE_SET_ADVERTISING_PARAMETERS_CMD = OCF_LE_SET_ADVERTISING_PARAMETERS | OGF_LE_CTL << 10 +LE_SET_ADVERTISING_DATA_CMD = OCF_LE_SET_ADVERTISING_DATA | OGF_LE_CTL << 10 +LE_SET_SCAN_RESPONSE_DATA_CMD = OCF_LE_SET_SCAN_RESPONSE_DATA | OGF_LE_CTL << 10 +LE_SET_ADVERTISE_ENABLE_CMD = OCF_LE_SET_ADVERTISE_ENABLE | OGF_LE_CTL << 10 + +LE_CREATE_CONN_CMD = OCF_LE_CREATE_CONN | OGF_LE_CTL << 10 +DISCONNECT_CMD = OCF_DISCONNECT | OGF_LINK_CTL << 10 + + + +# Generic Access Profile +# BT Spec V4.0, Volume 3, Part C, Section 18 + +# https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile + + +""" +0x01 «Flags» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.3 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.3 and 18.1 (v4.0)Core Specification Supplement, Part A, section 1.3 +0x02 «Incomplete List of 16-bit Service Class UUIDs» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.1 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.1 and 18.2 (v4.0)Core Specification Supplement, Part A, section 1.1 +0x03 «Complete List of 16-bit Service Class UUIDs» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.1 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.1 and 18.2 (v4.0)Core Specification Supplement, Part A, section 1.1 +0x04 «Incomplete List of 32-bit Service Class UUIDs» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.1 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, section 18.2 (v4.0)Core Specification Supplement, Part A, section 1.1 +0x05 «Complete List of 32-bit Service Class UUIDs» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.1 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, section 18.2 (v4.0)Core Specification Supplement, Part A, section 1.1 +0x06 «Incomplete List of 128-bit Service Class UUIDs» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.1 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.1 and 18.2 (v4.0)Core Specification Supplement, Part A, section 1.1 +0x07 «Complete List of 128-bit Service Class UUIDs» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.1 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.1 and 18.2 (v4.0)Core Specification Supplement, Part A, section 1.1 +0x08 «Shortened Local Name» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.2 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.2 and 18.4 (v4.0)Core Specification Supplement, Part A, section 1.2 +0x09 «Complete Local Name» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.2 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.2 and 18.4 (v4.0)Core Specification Supplement, Part A, section 1.2 +0x0A «Tx Power Level» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.5 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.5 and 18.3 (v4.0)Core Specification Supplement, Part A, section 1.5 +0x0D «Class of Device» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.6 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.5 and 18.5 (v4.0)Core Specification Supplement, Part A, section 1.6 +0x0E «Simple Pairing Hash C» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.6 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.5 and 18.5 (v4.0) +0x0E «Simple Pairing Hash C-192» Core Specification Supplement, Part A, section 1.6 +0x0F «Simple Pairing Randomizer R» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.6 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.5 and 18.5 (v4.0) +0x0F «Simple Pairing Randomizer R-192» Core Specification Supplement, Part A, section 1.6 +0x10 «Device ID» Device ID Profile v1.3 or later +0x10 «Security Manager TK Value» Bluetooth Core Specification:Vol. 3, Part C, sections 11.1.7 and 18.6 (v4.0)Core Specification Supplement, Part A, section 1.8 +0x11 «Security Manager Out of Band Flags» Bluetooth Core Specification:Vol. 3, Part C, sections 11.1.6 and 18.7 (v4.0)Core Specification Supplement, Part A, section 1.7 +0x12 «Slave Connection Interval Range» Bluetooth Core Specification:Vol. 3, Part C, sections 11.1.8 and 18.8 (v4.0)Core Specification Supplement, Part A, section 1.9 +0x14 «List of 16-bit Service Solicitation UUIDs» Bluetooth Core Specification:Vol. 3, Part C, sections 11.1.9 and 18.9 (v4.0)Core Specification Supplement, Part A, section 1.10 +0x15 «List of 128-bit Service Solicitation UUIDs» Bluetooth Core Specification:Vol. 3, Part C, sections 11.1.9 and 18.9 (v4.0)Core Specification Supplement, Part A, section 1.10 +0x16 «Service Data» Bluetooth Core Specification:Vol. 3, Part C, sections 11.1.10 and 18.10 (v4.0) +0x16 «Service Data - 16-bit UUID» Core Specification Supplement, Part A, section 1.11 +0x17 «Public Target Address» Bluetooth Core Specification:Core Specification Supplement, Part A, section 1.13 +0x18 «Random Target Address» Bluetooth Core Specification:Core Specification Supplement, Part A, section 1.14 +0x19 «Appearance» Bluetooth Core Specification:Core Specification Supplement, Part A, section 1.12 +0x1A «Advertising Interval» Bluetooth Core Specification:Core Specification Supplement, Part A, section 1.15 +0x1B «LE Bluetooth Device Address» Core Specification Supplement, Part A, section 1.16 +0x1C «LE Role» Core Specification Supplement, Part A, section 1.17 +0x1D «Simple Pairing Hash C-256» Core Specification Supplement, Part A, section 1.6 +0x1E «Simple Pairing Randomizer R-256» Core Specification Supplement, Part A, section 1.6 +0x1F «List of 32-bit Service Solicitation UUIDs» Core Specification Supplement, Part A, section 1.10 +0x20 «Service Data - 32-bit UUID» Core Specification Supplement, Part A, section 1.11 +0x21 «Service Data - 128-bit UUID» Core Specification Supplement, Part A, section 1.11 +0x22 «LE Secure Connections Confirmation Value» Core Specification Supplement Part A, Section 1.6 +0x23 «LE Secure Connections Random Value» Core Specification Supplement Part A, Section 1.6 +0x24 «URI» Bluetooth Core Specification:Core Specification Supplement, Part A, section 1.18 +0x25 «Indoor Positioning» Indoor Posiioning Service v1.0 or later +0x26 «Transport Discovery Data» Transport Discovery Service v1.0 or later +0x27 «LE Supported Features» Core Specification Supplement, Part A, Section 1.19 +0x28 «Channel Map Update Indication» Core Specification Supplement, Part A, Section 1.20 +0x29 «PB-ADV» +Mesh Profile Specification Section 5.2.1 +0x2A «Mesh Message» +Mesh Profile Specification Section 3.3.1 +0x2B «Mesh Beacon» +Mesh Profile Specification Section 3.9 +0x3D «3D Information Data» 3D Synchronization Profile, v1.0 or later +0xFF «Manufacturer Specific Data» Bluetooth Core Specification:Vol. 3, Part C, section 8.1.4 (v2.1 + EDR, 3.0 + HS and 4.0)Vol. 3, Part C, sections 11.1.4 and 18.11 (v4.0)Core Specification Supplement, Part A, section 1.4""" + +GAP_FLAGS = 0x01 +GAP_UUID_16BIT_INCOMPLETE = 0x02 +GAP_UUID_16BIT_COMPLETE = 0x03 +GAP_UUID_128BIT_INCOMPLETE = 0x06 +GAP_UUID_128BIT_COMPLETE = 0x07 +GAP_NAME_INCOMPLETE = 0x08 +GAP_NAME_COMPLETE = 0x09 +GAP_SERVICE_DATA = 0x16 +GAP_TX_POWER = 0x0A +GAP_MFG_DATA = 0xFF + + + +# see: https://github.com/ARMmbed/ble/blob/8d97fced5440d78c9557693b6d1632f1ab5d77b7/ble/GapAdvertisingData.h + +LE_LIMITED_DISCOVERABLE = 0x01 #, /**< Peripheral device is discoverable for a limited period of time. */ +LE_GENERAL_DISCOVERABLE = 0x02 #, /**< Peripheral device is discoverable at any moment. */ +BREDR_NOT_SUPPORTED = 0x04 #, /**< Peripheral device is LE only. */ +SIMULTANEOUS_LE_BREDR_C = 0x08 #, /**< Not relevant - central mode only. */ +SIMULTANEOUS_LE_BREDR_H = 0x10 # /**< Not relevant - central mode only. */ + + +# https://github.com/ARMmbed/ble/blob/8d97fced5440d78c9557693b6d1632f1ab5d77b7/ble/GapAdvertisingData.h + + + +HCI_LE_META_EVENT = 0x3e + +HCI_LE_META_EVENTS = { + 0x01 : "LE_Connection_Complete", + 0x02 : "LE_Advertising_Report", + 0x03 : "LE_Connection_Update_Complete", + 0x04 : "LE_Read_Remote_Used_Features_Complete", + 0x05 : "LE_Long_Term_Key_Request", + 0x06 : "LE_Remote_Connection_Parameter_Request" + } + + +HCI_EVENTS = { + 0x01 : "Inquiry_Complete", + 0x02 : "Inquiry_Result", + 0x03 : "Connection_Complete", + 0x04 : "Connection_Request", + 0x05 : "Disconnection_Complete", + 0x06 : "Authentication_Complete", + 0x07 : "Remote_Name_Request_Complete", + 0x08 : "Encryption_Change", + 0x09 : "Change_Connection_Link_Key_Complete", + 0x0a : "Master_Link_Key_Complete", + 0x0b : "Read_Remote_Supported_Features_Complete", + 0x0c : "Read_Remote_Version_Information_Complete", + 0x0d : "QoS_Setup_Complete", + 0x0e : "Command_Complete", + 0x0f : "Command_Status", + 0x10 : "Hardware_Error", + 0x11 : "Flush_Occurred", + 0x12 : "Role_Change", + 0x13 : "Number_Of_Completed_Packets", + 0x14 : "Mode_Change", + 0x15 : "Return_Link_Keys", + 0x16 : "PIN_Code_Request", + 0x17 : "Link_Key_Request", + 0x18 : "Link_Key_Notification", + 0x19 : "Loopback_Command", + 0x1a : "Data_Buffer_Overflow", + 0x1b : "Max_Slots_Change", + 0x1c : "Read_Clock_Offset_Complete", + 0x1d : "Connection_Packet_Type_Changed", + 0x1e : "QoS_Violation", + 0x20 : "Page_Scan_Repetition_Mode_Change", + 0x21 : "Flow_Specification_Complete", + 0x22 : "Inquiry_Result_with_RSSI", + 0x23 : "Read_Remote_Extended_Features_Complete", + 0x2c : "Synchronous_Connection_Complete", + 0x2d : "Synchronous_Connection_Changed", + 0x2e : "Sniff_Subrating", + 0x2f : "Extended_Inquiry_Result", + 0x30 : "Encryption_Key_Refresh_Complete", + 0x31 : "IO_Capability_Request", + 0x32 : "IO_Capability_Response", + 0x33 : "User_Confirmation_Request", + 0x34 : "User_Passkey_Request", + 0x35 : "Remote_OOB_Data_Request", + 0x36 : "Simple_Pairing_Complete", + 0x38 : "Link_Supervision_Timeout_Changed", + 0x39 : "Enhanced_Flush_Complete", + 0x3b : "User_Passkey_Notification", + 0x3c : "Keypress_Notification", + 0x3d : "Remote_Host_Supported_Features_Notification", + HCI_LE_META_EVENT : "LE_Meta_Event", + 0x40 : "Physical_Link_Complete", + 0x41 : "Channel_Selected", + 0x42 : "Disconnection_Physical_Link_Complete", + 0x43 : "Physical_Link_Loss_Early_Warning", + 0x44 : "Physical_Link_Recovery", + 0x45 : "Logical_Link_Complete", + 0x46 : "Disconnection_Logical_Link_Complete", + 0x47 : "Flow_Spec_Modify_Complete", + 0x48 : "Number_Of_Completed_Data_Blocks", + 0x4c : "Short_Range_Mode_Change_Complete", + 0x4d : "AMP_Status_Change", + 0x49 : "AMP_Start_Test", + 0x4a : "AMP_Test_End", + 0x4b : "AMP_Receiver_Report", + 0x4e : "Triggered_Clock_Capture", + 0x4f : "Synchronization_Train_Complete", + 0x50 : "Synchronization_Train_Received", + 0x51 : "Connectionless_Slave_Broadcast_Receive", + 0x52 : "Connectionless_Slave_Broadcast_Timeout", + 0x53 : "Truncated_Page_Complete", + 0x54 : "Slave_Page_Response_Timeout", + 0x55 : "Connectionless_Slave_Broadcast_Channel_Map_Change", + 0x56 : "Inquiry_Response_Notification", + 0x57 : "Authenticated_Payload_Timeout_Expired", + } + + diff --git a/bleson/core/hci/type_converters.py b/bleson/core/hci/type_converters.py new file mode 100644 index 0000000..3acb5f1 --- /dev/null +++ b/bleson/core/hci/type_converters.py @@ -0,0 +1,203 @@ +import struct + +from bleson.core.hci.constants import * +from bleson.core.hci.types import HCIPacket, HCIPayload +from bleson.core.types import Advertisement, BDAddress, UUID16, UUID128 +from bleson.logger import log + + + +def hex_string(data): + return ''.join('{:02x} '.format(x) for x in data) + + +def bytearray_to_hexstring(ba): + return hex_string(ba) + #return binascii.hexlify(ba) + + +def hexstring_to_bytearray(hexstr): + """" + hexstr: e.g. "de ad be ef 00" + """ + return bytearray.fromhex(hexstr) + +def event_to_string(event_code): + return HCI_EVENTS[event_code] + + +def parse_hci_event_packet(data): + evtcode, length = struct.unpack(" 127 else rssi_unsigned + if rssi == 127: + rssi = None # RSSI Not available + elif rssi >=20: + rssi = None # Reserverd range 20-126 + + return rssi + + +class AdvertisingDataConverters(object): + + @classmethod + def from_advertisement(cls, advertisement): + if advertisement.raw_data: + return advertisement.raw_data + + hci_payload = HCIPayload() + + # TODO: Advertisement should support __iter__ + + # WARN: Although the order isn't important, the Python tests are (currently) order sensitive. + + if advertisement.flags: + hci_payload.add_item(GAP_FLAGS, [advertisement.flags]) + + if advertisement.uuid16s: + hci_payload.add_item(GAP_UUID_16BIT_COMPLETE, advertisement.uuid16s) + # TODO: incompleteuuid16s + + if advertisement.uuid128s: + hci_payload.add_item(GAP_UUID_128BIT_COMPLETE, advertisement.uuid128s) + # TODO: incompleteuuid128s + + if advertisement.tx_pwr_lvl: + hci_payload.add_item(GAP_TX_POWER, advertisement.tx_pwr_lvl) + + if advertisement.name: + # TODO: 'incomplete' name + hci_payload.add_item(GAP_NAME_COMPLETE, advertisement.name.encode('ascii')) # TODO: check encoding is ok + + # TODO: more!! + + return hci_payload.data + + @classmethod + def from_hcipacket(cls, hci_packet: HCIPacket): + """ + uint8_t evt_type; + uint8_t bdaddr_type; + bdaddr_t bdaddr; + uint8_t length; + uint8_t data[0]; + """ + + if hci_packet.event_code != HCI_LE_META_EVENT and hci_packet.subevent_code != EVT_LE_ADVERTISING_REPORT: + raise ValueError("Invalid HCI Advertising Packet {}".format(hci_packet)) + + return cls.from_hcipayload(hci_packet.data) + + @classmethod + def from_hcipayload(cls, data): + data_info = "Data: {}".format(hex_string(data)) + pos_info = "POS : {}".format(''.join('{:02} '.format(x) for x in range(0, len(data)))) + log.debug(data_info) + log.debug(pos_info) + + num_reports = data[0] + log.debug("Num Reports {}".format(num_reports)) + + if num_reports !=1: + log.error("TODO: Only 1 Advertising report is supported, creating emtpy Advertisement") + # don't make it fatal + return Advertisement() + + # TODO: move these 2 LUTs to a better place + gap_adv_type = ['ADV_IND', 'ADV_DIRECT_IND', 'ADV_SCAN_IND', 'ADV_NONCONN_IND', 'SCAN_RSP'][data[1]] + gap_addr_type = ['PUBLIC', 'RANDOM', 'PUBLIC_IDENTITY', 'RANDOM_STATIC'][data[2]] + gap_addr = data[8:2:-1] + rssi = rssi_from_byte(data[-1]) + + + advertisement = Advertisement(address=BDAddress(gap_addr), rssi=rssi) + advertisement.type = gap_adv_type + advertisement.address_type = gap_addr_type + + pos = 10 + while pos < len(data)-1: + log.debug("POS={}".format(pos)) + length = data[pos] + gap_type = data[pos+1] + payload = data[pos+2:pos+2+length-1] + log.debug("Pos={} Type=0x{:02x} Len={} Payload={}".format(pos, gap_type, length, hex_string(payload))) + + if GAP_FLAGS == gap_type: + advertisement.flags = payload[0] + log.debug("Flags={:02x}".format(advertisement.flags)) + + elif GAP_UUID_16BIT_COMPLETE == gap_type: + uuids = [] + byte_pos = 0 + if len(payload) % 2 !=0: + raise ValueError("PAyload is not divisible by 2 for UUID16") + + while byte_pos < len(payload): + log.debug('byte_pos={}'.format(byte_pos)) + byte_pair = payload[byte_pos:byte_pos+2] + log.debug('byte pair = {}'.format(byte_pair)) + uuid = UUID16(byte_pair) + uuids.append(uuid) + byte_pos += 2 + + advertisement.uuid16s=uuids + + elif GAP_UUID_128BIT_COMPLETE == gap_type: + + # if length-1 > 16: + # log.warning("TODO: >1 UUID128's found, not yet split into individual elements") + #advertisement.uuid128s=[UUID128(payload)] + uuids = [] + byte_pos = 0 + if len(payload) % 16 !=0: + raise ValueError("Payload is not divisible by 16 for UUID128") + + while byte_pos < len(payload): + log.debug('byte_pos={}'.format(byte_pos)) + byte_list = payload[byte_pos:byte_pos+16] + log.debug('byte_list = {}'.format(byte_list)) + uuid = UUID128(byte_list) + uuids.append(uuid) + byte_pos += 16 + + advertisement.uuid128s=uuids + + log.debug(advertisement.uuid128s) + + elif GAP_NAME_INCOMPLETE == gap_type: + advertisement.name = payload + advertisement.name_is_complete = False + log.debug("Incomplete Name={}".format(advertisement.name)) + + elif GAP_NAME_COMPLETE == gap_type: + advertisement.name = payload + advertisement.name_is_complete = True + log.debug("Complete Name={}".format(advertisement.name)) + + elif GAP_SERVICE_DATA == gap_type: + advertisement.service_data = payload + log.debug("Service Data={}".format(advertisement.service_data)) + + elif GAP_MFG_DATA == gap_type: + advertisement.mfg_data = payload + log.debug("Manufacturer Data={}".format(advertisement.mfg_data)) + + else: + log.warning("TODO: Unhandled GAP type, pos={} type=0x{:02x} len={}".format(pos, gap_type, length)) + log.warning(data_info) + log.warning(pos_info) + + pos += length +1 + + + log.debug(advertisement) + return advertisement + + diff --git a/bleson/core/hci/types.py b/bleson/core/hci/types.py new file mode 100644 index 0000000..8610e79 --- /dev/null +++ b/bleson/core/hci/types.py @@ -0,0 +1,62 @@ +import struct +from collections import namedtuple + +from bleson import log + +HCIPacket = namedtuple('HCIPacket', 'event_name event_code subevent_code data length') + + +class HCIPayload: + + def __init__(self, with_data=b''): + self.data = bytes(with_data) + self._parse_data() + + def add_item(self, tag, value): + """ + :param tag: HCI packet type + :param value: array of byte-like data or homogeneous array of 'ValueObjects' + :return: self + """ + from bleson import ValueObject + log.debug("tag={} len={} data={} data={}".format(tag, len(value), type(value), value)) + + if all(isinstance(item, ValueObject) for item in value): + new_len=0 + for vo in value: + log.debug("VO len={} data={} bytes={}".format(len(vo), vo, bytes(vo))) + new_len += len(vo) + + new_data = self.data + struct.pack(" maxlen: + raise IndexError("Bad length byte 0x%02X in advertising data" % ll) + yield (tag, self.data[ofs+2:ofs+1+ll]) + ofs = ofs + 1 + ll + + def _parse_data(self): + self.tags={} + for (tag,value) in self: + self.tags[tag] = value + + def __str__(self): + return repr(self.tags) # TODO... \ No newline at end of file diff --git a/bleson/core/roles.py b/bleson/core/roles.py new file mode 100644 index 0000000..836f60f --- /dev/null +++ b/bleson/core/roles.py @@ -0,0 +1,43 @@ + +from bleson.interfaces.adapter import Adapter +from bleson.interfaces.role import Role + + +class Observer(Role): + + def __init__(self, adapter : Adapter, on_advertising_data=None): + self.adapter = adapter + self.on_advertising_data = on_advertising_data + + def start(self): + self.adapter.start_scanning() + + def stop(self): + self.adapter.stop_scanning() + + @property + def on_advertising_data(self): + return self.adapter.on_advtising_data + + @on_advertising_data.setter + def on_advertising_data(self, cb): + self.adapter.on_advertising_data=cb + + # Potential candidate to be a generator, maybe even an asyncio coroutine-generator? + # def discover_devices(self, timeout=None, filter=lambda advertisement: True): + # pass + +class Advertiser(Role): + + def __init__(self, adapter : Adapter, advertisement=None, scan_response=None): + self.adapter = adapter + self.advertisement = advertisement + self.scan_response = scan_response + + def start(self): + self.adapter.start_advertising(self.advertisement, self.scan_response) + + def stop(self): + self.adapter.stop_advertising() + + diff --git a/bleson/core/types.py b/bleson/core/types.py new file mode 100644 index 0000000..c7725cd --- /dev/null +++ b/bleson/core/types.py @@ -0,0 +1,242 @@ +from bleson.logger import log +from uuid import UUID + +# Collection of value objects. + +# They represent data types in the bluetooth spec. + +# Object equlity is not based on the objects address, but instead for example, may be based on a MAC (BDAddress) + +class ValueObject(object): + + def __bytes__(self): + raise NotImplementedError + + def __len__(self): + raise NotImplementedError + + # TODO: create from bytes, __init__ + + +class UUID16(ValueObject): + + def __init__(self, uuid, little_endian=True): + """ Create UUID16, non-int types are assumed to be little endian (reverse order)""" + + log.debug(("UUID16 type: {} value={}".format(type(uuid), uuid))) + if isinstance(uuid, memoryview): + uuid = uuid.tolist() + + if isinstance(uuid, int): + if not 0 < uuid < 0xffff: + raise ValueError('Invalid UUID16 value {}'.format(uuid)) + + elif isinstance(uuid, list) or isinstance(uuid, tuple): + if len(uuid) != 2: + raise ValueError('Unexpected address length of {} for {}'.format(len(uuid), uuid)) + uuid = (uuid[1] << 8) + (uuid[0] & 0xff) + elif isinstance(uuid, bytes) or isinstance(uuid, bytearray): + if len(uuid) != 2: + raise ValueError('Unexpected address length of {} for {}'.format(len(uuid), uuid)) + uuid = (uuid[1] << 8) + (uuid[0] & 0xff) + else: + raise TypeError('Unsupported UUID16 initialiser type: {}'.format(type(uuid))) + + if not little_endian: # swap + uuid = ( (uuid & 0xff) << 8 ) | ( (uuid & 0xff00) >>8) + self._uuid=uuid + log.debug(self) + + def __hash__(self): + return self._uuid + + def __eq__(self, other): + return isinstance(other, UUID16) and self._uuid == other._uuid + + def __repr__(self): + return "UUID16(0x{:02x})".format(self._uuid) + + def __bytes__(self): + return bytes([self._uuid & 0xff, (self._uuid >>8) & 0xff]) + + def __len__(self): + return 2 + + @property + def uuid(self): + return self._uuid + + @uuid.setter + def uuid(self, uuid): + raise NotImplemented + + +class UUID128(ValueObject): + + def __init__(self, uuid, little_endian=True): + """ Create UUID128, non-int types must be little endian (e.g. 'reversed' order w.r.t. displayed UUID)""" + + # TODO: accept 32bit and convert, xxxxxxxx-0000-1000-8000-00805F9B34FB + log.debug(("UUID128 type: {} value={}".format(type(uuid), uuid))) + + if isinstance(uuid, memoryview): + uuid = uuid.tobytes() + + # Massage 'uuid' into a string that the built-in UUID() contructor accepts + if isinstance(uuid, int): + if not 0 < uuid < 0xffff: + raise ValueError('Invalid UUID16 value {} fro UUID128 promotion'.format(uuid)) + uuid = UUID("0000{:04x}-0000-1000-8000-00805F9B34FB".format(uuid)).hex + + elif isinstance(uuid, str): + if not len(uuid) == 36: + raise ValueError('Invalid UUID128 value {}'.format(uuid)) + + elif isinstance(uuid, list) or isinstance(uuid, tuple) or isinstance(uuid, bytes) or isinstance(uuid, bytearray): + if len(uuid) != 16: + raise ValueError('Unexpected address length of {} for {}'.format(len(uuid), uuid)) + uuid = ''.join([format(c, '02x') for c in reversed(uuid)] ) + else: + raise TypeError('Unsupported UUID128 initialiser type: {}'.format(type(uuid))) + + + self._uuid_obj = UUID(uuid) + + if not little_endian: + self._uuid_obj = UUID(bytes=bytes(reversed(self._uuid_obj.bytes))) + + + self._uuid=self._uuid_obj.urn.replace('urn:uuid:', '') + log.debug(self) + + def __hash__(self): + return self._uuid + + def __eq__(self, other): + return isinstance(other, UUID128) and self._uuid == other._uuid + + def __repr__(self): + return "UUID128('{}')".format(self._uuid) + + + def __bytes__(self): + return bytes(reversed(self._uuid_obj.bytes)) + + def __len__(self): + return len(self._uuid_obj.bytes) + + @property + def uuid(self): + return self._uuid + + @uuid.setter + def uuid(self, uuid): + raise NotImplemented + + +class BDAddress(ValueObject): + + # TODO: add an overide for little_endian=True\False flag, default True, for list-like types + def __init__(self, address:str=None): + # expect a string of the format "xx:xx:xx:xx:xx:xx" + # or a 6 element; tuple, list, bytes or bytearray + if isinstance(address, str): + # TODO: regex based check + if len(address) != 6*2+5: + raise ValueError('Unexpected address length of {} for {}'.format(len(address, address))) + address = address + elif isinstance(address, list) or isinstance(address, tuple): + if len(address) != 6: + raise ValueError('Unexpected address length of {} for {}'.format(len(address, address))) + address = "%02x:%02x:%02x:%02x:%02x:%02x" % address + elif isinstance(address, bytes) or isinstance(address, bytearray): + if len(address) != 6: + raise ValueError('Unexpected address length of {} for {}'.format(len(address, address))) + address = ':'.join([format(c, '02x') for c in address]) + else: + raise TypeError('Unsupported address type: {}'.format(type(address))) + + # TODO: keep this for hashing but return bytearray for getter (defaulted to little endian) + self.address=address.upper() + log.debug(self) + + def __repr__(self): + return "BDAddress('{}')".format(self.address) + + def __eq__(self, other): + return isinstance(other, BDAddress) and self.address == other.address + + def __hash__(self): + return hash(self.address) + + +class Device(ValueObject): + + def __init__(self, address:BDAddress=None, name=None, rssi=None): + self.address = address + self.name = name + self.rssi = rssi + log.debug(self) + + def __repr__(self): + return "Device(address={}, name={}, rssi={})".format(self.address, self.name, self.rssi) + + def __eq__(self, other): + return isinstance(other, Device) and self.address == other.address + + def __hash__(self): + return hash(self.address) + + +class Advertisement(ValueObject): + + def __init__(self, name=None, address=None, rssi=None, tx_power=None, raw_data=None): + #TODO: use kwargs + self.flags = 6 # uint8 # default to LE_GENERAL_DISCOVERABLE | BREDR_NOT_SUPPORTED + self.type = None + self.address_type = None + self.address = address + self._name = name + self.name_is_complete = False # unsigned + self.tx_pwr_lvl = tx_power # unsigned + self.appearance = None # unit16 + self.uuid16s = [] # uuid16[] + self.uuid32s = [] # uuid32[] + self.uuid128s = [] # uuid12[] + self.service_data = None + #slave_itvl_range + self.svc_data_uuid16 = None # uuid16[] + self.public_tgt_addr = None # uint8[] + self.adv_itvl = None # uint16 + self.svc_data_uuid32 = None # uint8 + self.svc_data_uuid128 = None # uint8 + self.uri = None # unit8 + self.mfg_data = None # unit8 + self.rssi = rssi # really only part of an Advertisement Report... + + self.raw_data = raw_data + log.debug(self) + + + def __repr__(self): + # TODO: appearance etc... + return "Advertisement(flags=0x{:02x}, name={}, txpower={} uuid16s={} uuid128s={} rssi={} mfg_data={})".format( + self.flags, self.name, self.tx_pwr_lvl, self.uuid16s, self.uuid128s, self.rssi, self.mfg_data) + + + @property + def name(self): + return self._name + + @name.setter + def name(self, name): + if isinstance(name, bytearray): + self._name = str(name,'ascii') # TODO: is the name really UTF8 encoded according to the Core spec? + else: + self._name = str(name) # unit8 + + +class ScanResponse(Advertisement): + pass + + diff --git a/bleson/interfaces/__init__.py b/bleson/interfaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bleson/interfaces/adapter.py b/bleson/interfaces/adapter.py new file mode 100644 index 0000000..4299a4f --- /dev/null +++ b/bleson/interfaces/adapter.py @@ -0,0 +1,33 @@ +import abc + +class Adapter(object): + __metaclass__ = abc.ABCMeta + + + @abc.abstractmethod + def open(self): + raise NotImplementedError + + @abc.abstractmethod + def on(self): + raise NotImplementedError + + @abc.abstractmethod + def off(self): + raise NotImplementedError + + @abc.abstractmethod + def start_scanning(self): + raise NotImplementedError + + @abc.abstractmethod + def stop_scanning(self): + raise NotImplementedError + + @abc.abstractmethod + def start_advertising(self, advertisement, scan_response=None): + raise NotImplementedError + + @abc.abstractmethod + def stop_advertising(self): + raise NotImplementedError diff --git a/bleson/interfaces/provider.py b/bleson/interfaces/provider.py new file mode 100644 index 0000000..76ed7f6 --- /dev/null +++ b/bleson/interfaces/provider.py @@ -0,0 +1,10 @@ +import abc + + +class Provider(object): + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def get_adapter(cls, adapter_id=None): + """Return an Adapter instance, default to first one available""" + raise NotImplementedError diff --git a/bleson/interfaces/role.py b/bleson/interfaces/role.py new file mode 100644 index 0000000..dd38800 --- /dev/null +++ b/bleson/interfaces/role.py @@ -0,0 +1,20 @@ +import abc + +class Role(object): + __metaclass__ = abc.ABCMeta + + def __enter__(self): + self.start() + + def __exit__(self, type, value, traceback): + self.stop() + + @abc.abstractmethod + def start(self): + """Start the role.""" + raise NotImplementedError + + @abc.abstractmethod + def stop(self): + """Stop the role.""" + raise NotImplementedError diff --git a/bleson/logger.py b/bleson/logger.py new file mode 100644 index 0000000..66b3621 --- /dev/null +++ b/bleson/logger.py @@ -0,0 +1,13 @@ +from logging import basicConfig, getLogger, INFO, DEBUG, WARN, WARNING, ERROR + +LOGGER_NAME='bleson' +LOG_FORMAT= '%(asctime)s %(levelname)6s - %(filename)24s:%(lineno)3s - %(funcName)24s(): %(message)s' + +basicConfig(level=INFO, format=LOG_FORMAT) +log = getLogger(LOGGER_NAME) + +def set_level(level): + logger = getLogger(LOGGER_NAME) + previous_level = logger.level + logger.setLevel(level) + return previous_level \ No newline at end of file diff --git a/bleson/providers/__init__.py b/bleson/providers/__init__.py new file mode 100644 index 0000000..08fed0f --- /dev/null +++ b/bleson/providers/__init__.py @@ -0,0 +1,28 @@ +import sys + +from bleson.logger import log + +_provider = None + + +def get_provider(): + + global _provider + + if _provider is None: + if sys.platform.startswith('linux'): + from bleson.providers.linux.linux_provider import LinuxProvider + _provider = LinuxProvider() + elif sys.platform.startswith('darwin'): + from bleson.providers.macos.macos_provider import MacOSProvider + _provider = MacOSProvider() + + elif sys.platform.startswith('win32'): + from bleson.providers.win32.win32_provider import Win32Provider + _provider = Win32Provider() + else: + raise RuntimeError('Platform {0} is not supported!'.format(sys.platform)) + + log.debug("Provider is {}".format(_provider)) + + return _provider diff --git a/bleson/providers/linux/__init__.py b/bleson/providers/linux/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bleson/providers/linux/constants.py b/bleson/providers/linux/constants.py new file mode 100644 index 0000000..f743c1d --- /dev/null +++ b/bleson/providers/linux/constants.py @@ -0,0 +1,21 @@ + +HCIPY_HCI_CMD_STRUCT_HEADER = "1 else 10 + +adapter = get_provider().get_adapter() + +advertiser = Advertiser(adapter) +advertisement = Advertisement() +advertisement.name = "bleson" + +advertiser.advertisement = advertisement + +advertiser.start() +sleep(WAIT_TIME) +advertiser.stop() + diff --git a/examples/basic_advertiser_heartrate.py b/examples/basic_advertiser_heartrate.py new file mode 100644 index 0000000..97a9d0b --- /dev/null +++ b/examples/basic_advertiser_heartrate.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +import sys +from time import sleep + +from bleson.logger import log, set_level, DEBUG +from bleson import get_provider, Advertiser, Advertisement, UUID16, LE_GENERAL_DISCOVERABLE, BREDR_NOT_SUPPORTED + +previous_log_level = set_level(DEBUG) +# Get the wait time from the first script argument or default it to 10 seconds +WAIT_TIME = int(sys.argv[1]) if len(sys.argv)>1 else 10 + +adapter = get_provider().get_adapter() + +advertiser = Advertiser(adapter) + +advertisement = Advertisement() +advertisement.name = "Heart Rate" +advertisement.flags = LE_GENERAL_DISCOVERABLE | BREDR_NOT_SUPPORTED +advertisement.uuid16s = [UUID16(0x180a), UUID16(0x180d)] + +advertiser.advertisement = advertisement + +advertiser.start() +sleep(WAIT_TIME) +advertiser.stop() + +set_level(previous_log_level) \ No newline at end of file diff --git a/examples/basic_eddystone_beacon.py b/examples/basic_eddystone_beacon.py new file mode 100644 index 0000000..4e9587a --- /dev/null +++ b/examples/basic_eddystone_beacon.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +import sys +from time import sleep +from bleson import get_provider, EddystoneBeacon + +# Get the wait time from the first script argument or default it to 10 seconds +WAIT_TIME = int(sys.argv[1]) if len(sys.argv)>1 else 10 + +try: + adapter = get_provider().get_adapter() + + beacon = EddystoneBeacon(adapter) + beacon.url = 'https://www.bluetooth.com/' + beacon.start() + sleep(WAIT_TIME) + +except KeyboardInterrupt: + pass + +finally: + beacon.stop() diff --git a/examples/basic_observer.py b/examples/basic_observer.py new file mode 100644 index 0000000..56d5aea --- /dev/null +++ b/examples/basic_observer.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +import sys +from time import sleep +from bleson import get_provider, Observer + +# Get the wait time from the first script argument or default it to 10 seconds +WAIT_TIME = int(sys.argv[1]) if len(sys.argv)>1 else 10 + + +def on_advertisement(advertisement): + print(advertisement) + +adapter = get_provider().get_adapter() + +observer = Observer(adapter) +observer.on_advertising_data = on_advertisement + +observer.start() +sleep(WAIT_TIME) +observer.stop() + diff --git a/examples/context_advertiser.py b/examples/context_advertiser.py new file mode 100644 index 0000000..87b53e9 --- /dev/null +++ b/examples/context_advertiser.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +import sys +from time import sleep + +from bleson import get_provider, Advertiser, Advertisement + +# Get the wait time from the first script argument or default it to 10 seconds +WAIT_TIME = int(sys.argv[1]) if len(sys.argv)>1 else 10 + + +with Advertiser(get_provider().get_adapter(), Advertisement(name='bleson')): + sleep(WAIT_TIME) + diff --git a/examples/context_eddystone_beacon.py b/examples/context_eddystone_beacon.py new file mode 100644 index 0000000..e1039c5 --- /dev/null +++ b/examples/context_eddystone_beacon.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 + +import sys +from time import sleep +from bleson import get_provider, EddystoneBeacon + +# Get the wait time from the first script argument or default it to 10 seconds +WAIT_TIME = int(sys.argv[1]) if len(sys.argv)>1 else 10 + +with EddystoneBeacon(get_provider().get_adapter(), 'https://www.bluetooth.com/'): + sleep(WAIT_TIME) diff --git a/examples/context_observer.py b/examples/context_observer.py new file mode 100644 index 0000000..c459427 --- /dev/null +++ b/examples/context_observer.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +import sys +from time import sleep +from bleson import get_provider, Observer +from bleson.logger import log, set_level, DEBUG + +set_level(DEBUG) + + +# Get the wait time from the first script argument or default it to 10 seconds +WAIT_TIME = int(sys.argv[1]) if len(sys.argv)>1 else 10 + +with Observer(get_provider().get_adapter(), lambda device: log.info(device)): + sleep(WAIT_TIME) + diff --git a/sandpit/test_macos_pyobjc_advertiser.py b/sandpit/test_macos_pyobjc_advertiser.py new file mode 100644 index 0000000..86dc9d4 --- /dev/null +++ b/sandpit/test_macos_pyobjc_advertiser.py @@ -0,0 +1,54 @@ +import uuid +import threading +import objc +from PyObjCTools import AppHelper + +# https://gist.github.com/jeamland/11284662 + +objc.loadBundle("CoreBluetooth", globals(), + bundle_path=objc.pathForFramework( + u'/System/Library/Frameworks/IOBluetooth.framework/Versions/A/Frameworks/CoreBluetooth.framework')) + + +def main(): + delegate = TestCoreBluetoothAdvertisement() + manager = CBPeripheralManager.alloc() + + # https://developer.apple.com/documentation/corebluetooth/cbperipheralmanager/1393295-init + manager.initWithDelegate_queue_options_(delegate, None, None) + + try: + print("starting") + AppHelper.runConsoleEventLoop(installInterrupt=True) + except KeyboardInterrupt: + AppHelper.stopEventLoop() + + + +class TestCoreBluetoothAdvertisement(): + + def __init__(self): + self.manager = None + self.peripheral = None + self._connected = threading.Event() + + def peripheralManagerDidUpdateState_(self, manager): + print("peripheralManagerDidUpdateState_") + self.manager = manager + self.start_advertising() + + def start_advertising(self): + adv_data = { + 'CBAdvertisementDataLocalNameKey': 'bleson', + #'CBAdvertisementDataServiceUUIDsKey': CBUUID.UUIDWithString_(u'6E400001-B5A3-F393-E0A9-E50E24DCCA9E') + 'CBAdvertisementDataServiceUUIDsKey': CBUUID.UUIDWithString_(u'0xFFEF') + } + self.manager.startAdvertising_(adv_data) + + + def peripheralManagerDidStartAdvertising_error_(self, peripheral, error): + print("peripheralManagerDidStartAdvertising_error_ {} {}".format(peripheral, error)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sandpit/test_macos_pyobjc_dispatch_observer.py b/sandpit/test_macos_pyobjc_dispatch_observer.py new file mode 100644 index 0000000..643fbe7 --- /dev/null +++ b/sandpit/test_macos_pyobjc_dispatch_observer.py @@ -0,0 +1,129 @@ +import threading +from logging import getLogger, basicConfig, DEBUG +from time import sleep +import objc +from Foundation import * +from PyObjCTools import AppHelper +import CoreBluetooth + +from ctypes import cdll, Structure, POINTER, pointer, c_int, c_char_p, c_void_p, cast +# http://python.net/crew/theller/ctypes/tutorial.html + +#from objc import se +#objc.setObjCPointerIsError(True) + +OpaqueType = objc.createOpaquePointerType("OpaqueType", b"^{OpaqueType}", None) + + +basicConfig(level=DEBUG, format='%(asctime)s %(levelname)6s - %(funcName)24s(): %(message)s') +log = getLogger(__name__) + +USE_BACKGROUND_THREAD = True # When false OK, when true no CoreBluetooth events +USE_DISPATCH_QUEUE = True # When false OK, when true SIG + + +NULL_PTR = POINTER(c_int)() + +# Opaque structure that can be used to pass areound 'dispatch_queue_t' C type in a type-safe way +# see: https://stackoverflow.com/questions/5030730/is-it-acceptable-to-subclass-c-void-p-in-ctypes +class dispatch_queue_t(Structure): + pass + + +# Load the dispatch library +_lib = cdll.LoadLibrary("/usr/lib/system/libdispatch.dylib") + +_dispatch_queue_create = _lib.dispatch_queue_create +_dispatch_queue_create.argtypes = [c_char_p, c_void_p] +#_dispatch_queue_create.restype = POINTER(dispatch_queue_t) +_dispatch_queue_create.restype = c_void_p + +def dispatch_queue_create(name): + # https://developer.apple.com/documentation/dispatch/1453030-dispatch_queue_create + b_name = name.encode('utf-8') + c_name = c_char_p(b_name) + queue = _dispatch_queue_create(c_name, NULL_PTR) + + return queue + + +class TestCoreBluetooth(): + + def __init__(self): + self._manager = None + self._dispatch_queue = dispatch_queue_create('myq') if USE_DISPATCH_QUEUE else None + + + def start(self): + if USE_BACKGROUND_THREAD: + self._socket_poll_thread = threading.Thread(target=self._runloop, name='mythread') + self._socket_poll_thread.setDaemon(True) + self._socket_poll_thread.start() + else: + self._runloop() + + + # https://pythonhosted.org/pyobjc/core/intro.html#working-with-threads + def _runloop(self): + log.info("") + try: + pool = NSAutoreleasePool.alloc().init() + + self._manager = CoreBluetooth.CBCentralManager.alloc() + + # queue_ptr = self._dispatch_queue # the ctypes object of dispatch_queue_t* returned from dispatch_queue_create() + # queue_ptr = OpaqueType(c_void_p=self._dispatch_queue) + queue_ptr = objc.objc_object(c_void_p=self._dispatch_queue) + self._manager.initWithDelegate_queue_options_(self, queue_ptr, None) + + log.info('Starting RunLoop, dispatch_queue={}'.format(self._dispatch_queue)) + rc = AppHelper.runConsoleEventLoop(installInterrupt=True) + log.info("RC=",rc) + except Exception as e: + log.exception(e) + finally: + AppHelper.stopEventLoop() + if pool: + del pool + log.info("Ending") + + + # CoreBluetooth Protocol + + def centralManagerDidUpdateState_(self, manager): + log.info("") + self._manager.scanForPeripheralsWithServices_options_(None, None) + + def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(self, manager, peripheral, data, rssi): + log.info('Found: name={} rssi={} data={} '.format(peripheral.name(), rssi, data)) + + def centralManager_didConnectPeripheral_(self, manager, peripheral): + log.info("") + + def centralManager_didFailToConnectPeripheral_error_(self, manager, peripheral, error): + log.error(repr(error)) + + def centralManager_didDisconnectPeripheral_error_(self, manager, peripheral, error): + log.info("") + + def peripheral_didDiscoverServices_(self, peripheral, error): + log.info("") + + def peripheral_didDiscoverCharacteristicsForService_error_(self, peripheral, service, error): + log.info("") + + def peripheral_didWriteValueForCharacteristic_error_(self, peripheral, characteristic, error): + log.info("") + + def peripheral_didUpdateNotificationStateForCharacteristic_error_(self, peripheral, characteristic, error): + log.info("peripheral_didUpdateNotificationStateForCharacteristic_error_") + + + def peripheral_didUpdateValueForCharacteristic_error_(self, peripheral, characteristic, error): + log.error(repr(error)) + + +if __name__ == "__main__": + bt = TestCoreBluetooth() + bt.start() + sleep(10) \ No newline at end of file diff --git a/sandpit/test_macos_pyobjc_observer.py b/sandpit/test_macos_pyobjc_observer.py new file mode 100644 index 0000000..8e68254 --- /dev/null +++ b/sandpit/test_macos_pyobjc_observer.py @@ -0,0 +1,166 @@ +import time +import struct +from threading import Timer +import objc +from PyObjCTools import AppHelper + +# install: https://www.python.org/downloads/release/python-363/ +# pip3 install obcj + +# see: https://github.com/ppossemiers/BLECrazyflie_Python/blob/master/ble_crazyflie.py + + +objc.loadBundle("CoreBluetooth", globals(), + bundle_path=objc.pathForFramework( + u'/System/Library/Frameworks/IOBluetooth.framework/Versions/A/Frameworks/CoreBluetooth.framework')) + +#dfu_service = CBUUID.UUIDWithString_(u'00001530-1212-efde-1523-785feabcd123') +#dfu_characteristic = CBUUID.UUIDWithString_(u'00001531-1212-efde-1523-785feabcd123') +crazyflie_service = CBUUID.UUIDWithString_(u'6E400001-B5A3-F393-E0A9-E50E24DCCA9E') +crtp_characteristic = CBUUID.UUIDWithString_(u'6E400002-B5A3-F393-E0A9-E50E24DCCA9E') # TX char + +print(crazyflie_service) + +def main(): + # Ctrl + \ kills the script on MacOS + cf = BLECrazyFlie() + # add methods that the crazyflie executes + cf.add_callback(hover) + manager = CBCentralManager.alloc() + manager.initWithDelegate_queue_options_(cf, None, None) + + try: + print("starting") + AppHelper.runConsoleEventLoop(installInterrupt=True) + except KeyboardInterrupt: + AppHelper.stopEventLoop() + + +def hover(cf): + print('hover') + # take off + for i in range(2): + cf.send_setpoint(0, 0, 0, 50000) + time.sleep(0.5) + + # stop thrust, start hover + cf.set_param(11, 'b', 1) + cf.send_setpoint(0, 0, 0, 32767) + while 1: + cf.send_setpoint(0, 0, 0, 32767) + time.sleep(0.5) + + +class BLECrazyFlie(): + def __init__(self): + self.manager = None + self.peripheral = None + self.service = None + self.crtp_characteristic = None + self.connected = False + self.callbacks = [] + + self.init = False + + def send_setpoint(self, roll, pitch, yaw, thrust): + data = struct.pack(' 0000: 04 3e 21 02 01 00 00 1e ac e7 eb 27 b8 15 02 01 .>!........'.... + # 0010: 1a 03 03 aa fe 0d 16 aa fe 10 ed 00 67 6f 6f 67 ............goog + # 0020: 6c 65 00 bc le.. + + # hci_packet = '04 3e 21 02 01 00 00 1e ac e7 eb 27 b8 15 02 01 1a 03 03 aa fe 0d 16 aa fe 10 ed 00 67 6f 6f 67 6c 65 00 bc' + # 04 is HCI_EVENT_PACKET type, one of a handful of types recevied by the Socket listener + + hci_packet = parse_hci_event_packet(EDDYSTONE_ADV_REPORT) + + advertisement = AdvertisingDataConverters.from_hcipacket(hci_packet) + + self.assertEqual(0x1a, advertisement.flags) + self.assertEqual(-68, advertisement.rssi) + self.assertEqual(True, UUID16(0xfeaa) in advertisement.uuid16s) + self.assertEqual(1, len(advertisement.uuid16s)) + + self.assertEqual(b'\xaa\xfe\x10\xed\x00google\x00', advertisement.service_data) + + + def test_parse_microbit_advertisment_report(self): + + hci_packet = parse_hci_event_packet( BBC_MICROBIT_ADV_REPORT) + + advertisement = AdvertisingDataConverters.from_hcipacket(hci_packet) + + self.assertEqual(0x06, advertisement.flags) + self.assertEqual(-28, advertisement.rssi) + self.assertEqual('BBC micro:bit [tegip]', advertisement.name) + self.assertEqual(True, advertisement.name_is_complete) + + def test_parse_jspuck_advertisment_report(self): + + hci_packet = parse_hci_event_packet(JSPUCK_ADV_REPORT) + + advertisement = AdvertisingDataConverters.from_hcipacket(hci_packet) + + self.assertEqual(0x05, advertisement.flags) + self.assertEqual(-84, advertisement.rssi) + self.assertEqual('Puck.js 7b43', advertisement.name) + self.assertEqual(True, advertisement.name_is_complete) + + def test_parse_jspuck_advertisment_report(self): + + hci_packet = parse_hci_event_packet(UUID128_ADV_REPORT) + advertisement = AdvertisingDataConverters.from_hcipacket(hci_packet) + + + def test_parse_bluefruit52_advertisment_report(self): + + hci_packet = parse_hci_event_packet(BLUEFRUIT52_ADV_REPORT) + advertisement = AdvertisingDataConverters.from_hcipacket(hci_packet) + + + def test_parse_heartrate_advertisment_report(self): + + hci_packet = parse_hci_event_packet(HEARTRATE_ADV_REPORT) + log.debug(hci_packet) + advertisement = AdvertisingDataConverters.from_hcipacket(hci_packet) + self.assertEqual(0x1a, advertisement.flags) + self.assertEqual(-55, advertisement.rssi) + self.assertEqual('Heart Rate', advertisement.name) + self.assertEqual(True, advertisement.name_is_complete) + self.assertEqual(True, UUID16(0x180a) in advertisement.uuid16s) + self.assertEqual(True, UUID16(0x180d) in advertisement.uuid16s) + self.assertEqual(2, len(advertisement.uuid16s)) + + def test_parse_thermometer_advertisment_report(self): + + hci_packet = parse_hci_event_packet(THERMOMETER_ADV_REPORT) + log.debug(hci_packet) + advertisement = AdvertisingDataConverters.from_hcipacket(hci_packet) + self.assertEqual(0x1a, advertisement.flags) + self.assertEqual(-74, advertisement.rssi) + self.assertEqual('Health Thermometer', advertisement.name) + self.assertEqual(True, advertisement.name_is_complete) + + log.debug(advertisement.uuid16s) + + self.assertEqual(True, UUID16(0x180a) in advertisement.uuid16s) + self.assertEqual(True, UUID16(0x1809) in advertisement.uuid16s) + self.assertEqual(2, len(advertisement.uuid16s)) + + + def test_parse_nrf5x_lefacy_dfu_advertisement_report(self): + + hci_packet = parse_hci_event_packet(NRF51X_LEGACY_DFU_ADV_REPORT) + log.debug(hci_packet) + + advertisement = AdvertisingDataConverters.from_hcipacket(hci_packet) + self.assertEqual(0x1a, advertisement.flags) + self.assertEqual(-71, advertisement.rssi) + self.assertEqual('nRF5x', advertisement.name) + log.debug(advertisement.uuid128s) + self.assertEqual(True, UUID128('00001530-1212-efde-1523-785feabcd123') in advertisement.uuid128s) + self.assertEqual(1, len(advertisement.uuid128s)) \ No newline at end of file diff --git a/tests/test_internal_type_uuid.py b/tests/test_internal_type_uuid.py new file mode 100644 index 0000000..689730f --- /dev/null +++ b/tests/test_internal_type_uuid.py @@ -0,0 +1,25 @@ +import unittest + +from bleson.core.types import UUID16, UUID128 + + +class TestUUID(unittest.TestCase): + + def test_uuid16_init(self): + u1 = UUID16(bytearray([0x0d, 0x1a])) + u2 = UUID16(0x1a0d) + + self.assertEqual(0x1a0d, u1.uuid) + self.assertEqual(0x1a0d, u2.uuid) + self.assertEqual(u1, u2) + + def test_uuid128_init(self): + u1 = UUID128([0x23, 0xD1, 0xBC, 0xEA, 0x5F, 0x78, 0x23, 0x15, 0xDE, 0xEF, 0x12, 0x12, 0x30, 0x15, 0x00, 0x00]) + u2 = UUID128('00001530-1212-efde-1523-785feabcd123') + u3 = UUID128('12345678-1212-efde-1523-785feabcd123') + u4 = UUID128(0xFEFF) + self.assertEqual('00001530-1212-efde-1523-785feabcd123', u1.uuid) + self.assertEqual('00001530-1212-efde-1523-785feabcd123', u2.uuid) + self.assertEqual(u1, u2) + self.assertNotEqual(u3, u1) + self.assertNotEqual(u4, u1) diff --git a/tests/test_role_advertiser.py b/tests/test_role_advertiser.py new file mode 100644 index 0000000..686d012 --- /dev/null +++ b/tests/test_role_advertiser.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +import unittest +from time import sleep + +from bleson import get_provider +from bleson.core.types import BDAddress, Advertisement +from bleson.core.roles import Advertiser +from bleson.logger import log + +ADVERTISE_TIME = 5 # seconds + +MICROBIT1_BDADDR=BDAddress('f5:3a:c9:b0:15:f6') + + +class TestAdvertiser(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.adapter = get_provider().get_adapter() + + def test_advertiser(self): + + advertisement = Advertisement() + advertisement.name = 'bleson' + + advertiser = Advertiser(self.adapter) + advertiser.advertisement = advertisement + + advertiser.start() + log.info("Starting Advertiser role for {} seconds, advertised name={}".format(ADVERTISE_TIME, advertisement.name)) + + sleep(ADVERTISE_TIME) + advertiser.stop() + diff --git a/tests/test_role_dual_adapters.py b/tests/test_role_dual_adapters.py new file mode 100644 index 0000000..55beedb --- /dev/null +++ b/tests/test_role_dual_adapters.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 + +# This test expects the host to have 2 Bluetooth LE adapters available. +# e.g. a Pi3/ZeroW with a BluetoothLE USB dongle. + +import unittest +from time import sleep + +from bleson import get_provider +from bleson.core.types import Advertisement +from bleson.core.roles import Observer, Advertiser +from bleson.beacons.eddystone import EddystoneBeacon +from bleson.logger import log, set_level, DEBUG, INFO + + +TEST_DURATION_SECS = 5 + + +class TestDualAdapters(unittest.TestCase): + + @classmethod + def setUpClass(cls): + provider = get_provider() + cls.adapter0 = provider.get_adapter(0) # Used for the Observer role + cls.adapter1 = provider.get_adapter(1) # Used for the Advertiser role + + # Turn on bleson DEBUG logging, keeping note of the previous setting. + cls.previous_log_level = set_level(DEBUG) + + @classmethod + def tearDownClass(cls): + # Restore logging level, in case we're running in a test suite + set_level(cls.previous_log_level) + + def test_observer_advertiser_pair(self): + + # ---------------------------------------- + # Setup the Observer, on adatper 0 + + observer = Observer(self.adapter0) + + # The observer callback records the found device BDAddress + + found_addresses = set() + + def advertisement_update(advertisement): + log.info("Found: {}".format(advertisement)) + found_addresses.add(advertisement.address) # instance of 'BDAddress' + + observer.on_advertising_data = advertisement_update + + + # ---------------------------------------- + # Setup the Advertiser, on adapter 1 + + advertiser = Advertiser(self.adapter1) + + advertisement = Advertisement() + advertisement.name = "bleson" + + advertiser.advertisement = advertisement + + + # ---------------------------------------- + # Start the Observer and the Advertiser. + + observer.start() + advertiser.start() + + # wait... + log.info("Starting Advertiser & Observer roles for {} seconds, advertised name={}".format(TEST_DURATION_SECS, advertisement.name)) + + sleep(TEST_DURATION_SECS) + + # Stop + advertiser.stop() + observer.stop() + + + # ---------------------------------------- + # Did the left hand find the right hand? + + log.info("Observer's address: {}".format(self.adapter0.device.address)) + log.info("Advertiser's address: {}".format(self.adapter1.device.address)) + log.info("Observed BDAddresses: {}".format(found_addresses)) + + self.assertTrue(self.adapter1.device.address in found_addresses) + + + + def test_observer_beacon_pair(self): + + observer = Observer(self.adapter0) + found_addresses = set() + + def advertisement_update(advertisement): + log.info("Found: {}".format(advertisement)) + found_addresses.add(advertisement.address) # instance of 'BDAddress' + + observer.on_advertising_data = advertisement_update + + beacon = EddystoneBeacon(self.adapter1) + + + # ---------------------------------------- + # Start the Observer and the Advertiser. + + observer.start() + beacon.start() + + # wait... + log.info("Starting Advertiser & Observer roles for {} seconds, advertised url={}".format(TEST_DURATION_SECS, beacon.url)) + + sleep(TEST_DURATION_SECS) + + # Stop + beacon.stop() + observer.stop() + + + # ---------------------------------------- + # Did the left hand find the right hand? + + log.info("Observer's address: {}".format(self.adapter0.device.address)) + log.info("Beacons's address: {}".format(self.adapter1.device.address)) + log.info("Observed BDAddresses: {}".format(found_addresses)) + + self.assertTrue(self.adapter1.device.address in found_addresses) diff --git a/tests/test_role_eddystone_beacon.py b/tests/test_role_eddystone_beacon.py new file mode 100644 index 0000000..4575c8a --- /dev/null +++ b/tests/test_role_eddystone_beacon.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +import unittest +from time import sleep + +from bleson import get_provider +from bleson.beacons.eddystone import EddystoneBeacon +from bleson.logger import log + +BEACON_TIME = 10 + +class TestBeacons(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.adapter = get_provider().get_adapter() + + def test_eddystone(self): + beacon = EddystoneBeacon(self.adapter, 'https://www.google.com/') + beacon.start() + log.info("Starting beacon for {} seconds, url={}".format(BEACON_TIME, beacon.url)) + sleep(BEACON_TIME) + beacon.stop() diff --git a/tests/test_role_observer.py b/tests/test_role_observer.py new file mode 100644 index 0000000..194a361 --- /dev/null +++ b/tests/test_role_observer.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +import unittest +from time import sleep + + +from bleson import get_provider +from bleson.core.types import BDAddress +from bleson.core.roles import Observer +from bleson.logger import log, set_level, DEBUG + +SCANTIME = 5 # seconds + +MICROBIT1_BDADDR=BDAddress('f5:3a:c9:b0:15:f6') + + +class TestRoles(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.adapter = get_provider().get_adapter() + # Turn on bleson DEBUG logging, keeping note of the previous setting. + cls.previous_log_level = set_level(DEBUG) + + @classmethod + def tearDownClass(cls): + # Restore logging level, in case we're running in a test suite + set_level(cls.previous_log_level) + + def test_observer(self): + + found_devices = set() + observer = Observer(self.adapter) + + def advertisement_update(advertisement): + found_devices.add(advertisement.address) + + observer.on_advertising_data = advertisement_update + + observer.start() + log.info("Starting Observer role for {} seconds".format(SCANTIME)) + + sleep(SCANTIME) + observer.stop() + + log.info("Found: {}".format(found_devices)) + + self.assertTrue(MICROBIT1_BDADDR in found_devices) +