Skip to content

Commit

Permalink
Implemented iBeacon support.
Browse files Browse the repository at this point in the history
  • Loading branch information
citruz committed Apr 3, 2017
1 parent 7fb1516 commit 54ac661
Show file tree
Hide file tree
Showing 17 changed files with 235 additions and 34 deletions.
39 changes: 33 additions & 6 deletions README.rst
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
BeaconTools - Universal beacon scanning
=======================================
|Build Status| |Coverage Status|
|Build Status| |Coverage Status| |Requirements Status|

A Python library for working with various types of Bluetooth LE Beacons.

Currently supported types are:

* `Eddystone Beacons <https://github.com/google/eddystone/>`__
* `Apple iBeacons <https://developer.apple.com/ibeacon/>`__ (coming soon)
* `Apple iBeacons <https://developer.apple.com/ibeacon/>`__

The BeaconTools library has two main components:

Expand Down Expand Up @@ -56,25 +56,52 @@ Scanner
~~~~~~~
.. code:: python
import time
from beacontools import BeaconScanner, EddystoneTLMFrame, EddystoneFilter
def callback(bt_addr, packet, additional_info):
print("<%s> %s %s" % (bt_addr, packet, additional_info))
def callback(bt_addr, rssi, packet, additional_info):
print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info))
# scan for all TLM frames of beacons in the namespace "12345678901234678901"
scanner = BeaconScanner(callback,
device_filter=EddystoneFilter(namespace="12345678901234678901"),
packet_filter=EddystoneTLMFrame
)
scanner.start()
time.sleep(10)
scanner.stop()
.. code:: python
import time
from beacontools import BeaconScanner, IBeaconFilter
def callback(bt_addr, rssi, packet, additional_info):
print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info))
# scan for all iBeacon advertisements from beacons with the specified uuid
scanner = BeaconScanner(callback,
device_filter=IBeaconFilter(uuid="e5b9e3a6-27e2-4c36-a257-7698da5fc140")
)
scanner.start()
time.sleep(5)
scanner.stop()
Changelog
---------

* 0.1.2 - Initial release
* 1.0.0
* Implemented iBeacon support
* Added rssi to callback function.
* 0.1.2
* Initial release


.. |Build Status| image:: https://travis-ci.org/citruz/beacontools.svg?branch=master
:target: https://travis-ci.org/citruz/beacontools
.. |Coverage Status| image:: https://coveralls.io/repos/github/citruz/beacontools/badge.svg?branch=master
:target: https://coveralls.io/github/citruz/beacontools?branch=master
.. |Requirements Status| image:: https://requires.io/github/citruz/beacontools/requirements.svg?branch=master
:target: https://requires.io/github/citruz/beacontools/requirements/?branch=master
1 change: 1 addition & 0 deletions beacontools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
from .parser import parse_packet
from .packet_types.eddystone import EddystoneUIDFrame, EddystoneURLFrame, \
EddystoneEncryptedTLMFrame, EddystoneTLMFrame
from .packet_types.ibeacon import IBeaconAdvertisement
from .device_filters import IBeaconFilter, EddystoneFilter, BtAddrFilter
4 changes: 4 additions & 0 deletions beacontools/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@
0x0c: ".biz",
0x0d: ".gov",
}

# for iBeacons
IBEACON_COMPANY_ID = b"\x4c\x00"
IBEACON_PROXIMITY_TPYE = b"\x02\x15"
5 changes: 4 additions & 1 deletion beacontools/device_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ def matches(self, filter_props):
if filter_props is None:
return False

found_one = False
for key, value in filter_props.items():
if key in self.properties and value != self.properties[key]:
return False
elif key in self.properties and value == self.properties[key]:
found_one = True

return True
return found_one

class IBeaconFilter(DeviceFilter):
"""Filter for iBeacon."""
Expand Down
1 change: 1 addition & 0 deletions beacontools/packet_types/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"""Packets supported by the parser."""
from .eddystone import EddystoneUIDFrame, EddystoneURLFrame, EddystoneEncryptedTLMFrame, \
EddystoneTLMFrame
from .ibeacon import IBeaconAdvertisement
40 changes: 40 additions & 0 deletions beacontools/packet_types/ibeacon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Packet classes for iBeacon beacons."""
from ..utils import data_to_uuid

class IBeaconAdvertisement(object):
"""iBeacon advertisement."""

def __init__(self, data):
self._uuid = data_to_uuid(data['uuid'])
self._major = data['major']
self._minor = data['minor']
self._tx_power = data['tx_power']

@property
def tx_power(self):
"""Calibrated Tx power at 0 m."""
return self._tx_power

@property
def uuid(self):
"""16-byte uuid."""
return self._uuid

@property
def major(self):
"""2-byte major identifier."""
return self._major

@property
def minor(self):
"""2-byte minor identifier."""
return self._minor

@property
def properties(self):
"""Get beacon properties."""
return {'uuid': self.uuid, 'major': self.major, 'minor': self.minor}

def __str__(self):
return "IBeaconAdvertisement<tx_power: %d, uuid: %s, major: %d, minor: %d>" \
% (self.tx_power, self.uuid, self.major, self.minor)
20 changes: 18 additions & 2 deletions beacontools/parser.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
"""Beacon advertisement parser."""
from construct import ConstructError

from .structs import EddystoneFrame
from .structs import EddystoneFrame, IBeaconAdvertisingPacket
from .packet_types import EddystoneUIDFrame, EddystoneURLFrame, EddystoneEncryptedTLMFrame, \
EddystoneTLMFrame
EddystoneTLMFrame, IBeaconAdvertisement
from .const import EDDYSTONE_TLM_UNENCRYPTED, EDDYSTONE_TLM_ENCRYPTED, SERVICE_DATA_TYPE, \
EDDYSTONE_UID_FRAME, EDDYSTONE_TLM_FRAME, EDDYSTONE_URL_FRAME


def parse_packet(packet):
"""Parse a beacon advertisement packet."""
frame = parse_eddystone_packet(packet)
if frame is None:
frame = parse_ibeacon_packet(packet)
return frame

def parse_eddystone_packet(packet):
"""Parse an eddystone beacon advertisement packet."""
try:
frame = EddystoneFrame.parse(packet)
for tlv in frame:
Expand All @@ -31,3 +38,12 @@ def parse_packet(packet):
return None

return None

def parse_ibeacon_packet(packet):
"""Parse an ibeacon beacon advertisement packet."""
try:
pkt = IBeaconAdvertisingPacket.parse(packet)
return IBeaconAdvertisement(pkt)

except ConstructError:
return None
17 changes: 9 additions & 8 deletions beacontools/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .packet_types import EddystoneUIDFrame, EddystoneURLFrame, \
EddystoneEncryptedTLMFrame, EddystoneTLMFrame
from .device_filters import BtAddrFilter, DeviceFilter
from .utils import is_packet_type, is_one_of, to_int
from .utils import is_packet_type, is_one_of, to_int, bin_to_int

LE_META_EVENT = 0x3e
OGF_LE_CTL = 0x08
Expand Down Expand Up @@ -108,14 +108,15 @@ def toggle_scan(self, enable):

def process_packet(self, pkt):
"""Parse the packet and call callback if one of the filters matches."""
# check if this is an eddystone packet before parsing
# check if this could be a valid packet before parsing
# this reduces the CPU load significantly
if pkt[19:21] != b"\xaa\xfe":
if (pkt[19:21] != b"\xaa\xfe") and (pkt[19:21] != b"\x4c\x00"):
return

bt_addr = bt_addr_to_string(pkt[7:13])
rssi = bin_to_int(pkt[-1])
# strip bluetooth address and parse packet
packet = parse_packet(pkt[14:])
packet = parse_packet(pkt[14:-1])

# return if packet was not an beacon advertisement
if not packet:
Expand All @@ -131,12 +132,12 @@ def process_packet(self, pkt):

if self.device_filter is None and self.packet_filter is None:
# no filters selected
self.callback(bt_addr, packet, properties)
self.callback(bt_addr, rssi, packet, properties)

elif self.device_filter is None:
# filter by packet type
if is_one_of(packet, self.packet_filter):
self.callback(bt_addr, packet, properties)
self.callback(bt_addr, rssi, packet, properties)
else:
# filter by device and packet type
if self.packet_filter and not is_one_of(packet, self.packet_filter):
Expand All @@ -147,11 +148,11 @@ def process_packet(self, pkt):
for filtr in self.device_filter:
if isinstance(filtr, BtAddrFilter):
if filtr.matches({'bt_addr':bt_addr}):
self.callback(bt_addr, packet, properties)
self.callback(bt_addr, rssi, packet, properties)
return

elif filtr.matches(properties):
self.callback(bt_addr, packet, properties)
self.callback(bt_addr, rssi, packet, properties)
return

def save_bt_addr(self, packet, bt_addr):
Expand Down
1 change: 1 addition & 0 deletions beacontools/structs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
"""Packets supported by the parser."""
from .eddystone import EddystoneFrame
from .ibeacon import IBeaconAdvertisingPacket
18 changes: 18 additions & 0 deletions beacontools/structs/ibeacon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""All low level structures used for parsing eddystone packets."""
from construct import Struct, Byte, Const, Int8sl, Array, Int16ub

from ..const import IBEACON_COMPANY_ID, IBEACON_PROXIMITY_TPYE

# pylint: disable=invalid-name

IBeaconAdvertisingPacket = Struct(
"flags" / Const(b"\x02\x01\x06"),
"length" / Const(b"\x1A"),
"type" / Const(b"\xFF"),
"company_id" / Const(IBEACON_COMPANY_ID),
"beacon_type" / Const(IBEACON_PROXIMITY_TPYE),
"uuid" / Array(16, Byte),
"major" / Int16ub,
"minor" / Int16ub,
"tx_power" / Int8sl,
)
17 changes: 15 additions & 2 deletions beacontools/utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""Utilities for byte conversion."""
from binascii import hexlify
import array
import struct


def data_to_hexstring(data):
"""Convert an array of binary data to the hex representation as a string."""
return hexlify(data_to_binstring(data)).decode('ascii')

def data_to_uuid(data):
"""Convert an array of binary data to the iBeacon uuid format."""
string = data_to_hexstring(data)
return string[0:8]+'-'+string[8:12]+'-'+string[12:16]+'-'+string[16:20]+'-'+string[20:32]

def data_to_binstring(data):
"""Convert an array of binary data to a binary string."""
return array.array('B', data).tostring()
Expand All @@ -29,13 +35,20 @@ def is_one_of(obj, types):
def is_packet_type(cls):
"""Check if class is one the packet types."""
from .packet_types import EddystoneUIDFrame, EddystoneURLFrame, \
EddystoneEncryptedTLMFrame, EddystoneTLMFrame
EddystoneEncryptedTLMFrame, EddystoneTLMFrame, IBeaconAdvertisement
return (cls in [EddystoneURLFrame, EddystoneUIDFrame, EddystoneEncryptedTLMFrame, \
EddystoneTLMFrame])
EddystoneTLMFrame, IBeaconAdvertisement])

def to_int(string):
"""Convert a one element byte string to int for python 2 support."""
if isinstance(string, str):
return ord(string[0])
else:
return string

def bin_to_int(string):
"""Convert a one element byte string to signed int for python 2 support."""
if isinstance(string, str):
return struct.unpack("b", string)[0]
else:
return struct.unpack("b", bytes([string]))[0]
11 changes: 11 additions & 0 deletions examples/parser_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,14 @@
print("Data: %s" % enc_tlm_frame.encrypted_data)
print("Salt: %d" % enc_tlm_frame.salt)
print("Mic: %d" % enc_tlm_frame.mic)

print("-----")

# iBeacon Advertisement
ibeacon_packet = b"\x02\x01\x06\x1a\xff\x4c\x00\x02\x15\x41\x41\x41\x41\x41\x41\x41\x41\x41" \
b"\x41\x41\x41\x41\x41\x41\x41\x00\x01\x00\x01\xf8"
adv = parse_packet(ibeacon_packet)
print("UUID: %s" % adv.uuid)
print("Major: %d" % adv.major)
print("Minor: %d" % adv.minor)
print("TX Power: %d" % adv.tx_power)
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import time
from beacontools import BeaconScanner, EddystoneTLMFrame, EddystoneFilter

def callback(bt_addr, packet, additional_info):
print("<%s> %s %s" % (bt_addr, packet, additional_info))
def callback(bt_addr, rssi, packet, additional_info):
print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info))

# scan for all TLM frames of beacons in the namespace "12345678901234678901"
scanner = BeaconScanner(callback,
device_filter=EddystoneFilter(namespace="12345678901234678901"),
packet_filter=EddystoneTLMFrame
Expand Down
13 changes: 13 additions & 0 deletions examples/scanner_ibeacon_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import time
from beacontools import BeaconScanner, IBeaconFilter

def callback(bt_addr, rssi, packet, additional_info):
print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info))

# scan for all iBeacon advertisements from beacons with the specified uuid
scanner = BeaconScanner(callback,
device_filter=IBeaconFilter(uuid="e5b9e3a6-27e2-4c36-a257-7698da5fc140")
)
scanner.start()
time.sleep(5)
scanner.stop()
14 changes: 13 additions & 1 deletion tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import unittest

from beacontools import parse_packet, EddystoneUIDFrame, EddystoneURLFrame, \
EddystoneEncryptedTLMFrame, EddystoneTLMFrame
EddystoneEncryptedTLMFrame, EddystoneTLMFrame, IBeaconAdvertisement

class TestParser(unittest.TestCase):
"""Test the parser."""
Expand Down Expand Up @@ -72,3 +72,15 @@ def test_eddystone_tlm_enc(self):
self.assertEqual(frame.salt, 44510)
self.assertEqual(frame.mic, 65470)
self.assertIsNotNone(str(frame))

def test_ibeacon(self):
"""Test iBeacon advertisement."""
ibeacon_packet = b"\x02\x01\x06\x1a\xff\x4c\x00\x02\x15\x41\x42\x43\x44\x45\x46\x47\x48"\
b"\x49\x40\x41\x42\x43\x44\x45\x46\x00\x01\x00\x02\xf8"
frame = parse_packet(ibeacon_packet)
self.assertIsInstance(frame, IBeaconAdvertisement)
self.assertEqual(frame.uuid, "41424344-4546-4748-4940-414243444546")
self.assertEqual(frame.major, 1)
self.assertEqual(frame.minor, 2)
self.assertEqual(frame.tx_power, -8)
self.assertIsNotNone(str(frame))
Loading

0 comments on commit 54ac661

Please sign in to comment.