From b6c9bddeb2f26074541101288e4cd9ffaacf77d3 Mon Sep 17 00:00:00 2001 From: Kai Oliver Quambusch Date: Thu, 13 Dec 2018 01:01:56 +0100 Subject: [PATCH 1/7] fixes Issue #30, improved MAC validation --- beacontools/__init__.py | 1 + beacontools/device_filters.py | 41 +++++++++++++++++++++++++---------- beacontools/utils.py | 21 +++++++++++++++++- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/beacontools/__init__.py b/beacontools/__init__.py index 2d1d93a..5af1943 100644 --- a/beacontools/__init__.py +++ b/beacontools/__init__.py @@ -8,3 +8,4 @@ from .packet_types.ibeacon import IBeaconAdvertisement from .packet_types.estimote import EstimoteTelemetryFrameA, EstimoteTelemetryFrameB from .device_filters import IBeaconFilter, EddystoneFilter, BtAddrFilter, EstimoteFilter +from .utils import is_valid_mac diff --git a/beacontools/device_filters.py b/beacontools/device_filters.py index 79db99f..a74e39b 100644 --- a/beacontools/device_filters.py +++ b/beacontools/device_filters.py @@ -1,5 +1,8 @@ """Filters passed to the BeaconScanner to filter results.""" +from .utils import is_valid_mac + + class DeviceFilter(object): """Base class for all device filters. Should not be used by itself.""" @@ -21,53 +24,67 @@ def matches(self, filter_props): return found_one + def __repr__(self): + return "{}({})".format( + self.__class__.__name__, + ", ".join(["=".join((k, str(v),)) for k, v in self.properties.items()])) + + class IBeaconFilter(DeviceFilter): """Filter for iBeacon.""" def __init__(self, uuid=None, major=None, minor=None): """Initialize filter.""" super(IBeaconFilter, self).__init__() - if not uuid and not major and not minor: + if uuid is None and major is None and minor is None: raise ValueError("IBeaconFilter needs at least one argument set") - if uuid: + if uuid is not None: self.properties['uuid'] = uuid - if major: + if major is not None: self.properties['major'] = major - if minor: + if minor is not None: self.properties['minor'] = minor + class EddystoneFilter(DeviceFilter): """Filter for Eddystone beacons.""" def __init__(self, namespace=None, instance=None): """Initialize filter.""" super(EddystoneFilter, self).__init__() - if not namespace and not instance: + if namespace is None and instance is None: raise ValueError("EddystoneFilter needs at least one argument set") - if namespace: + if namespace is not None: self.properties['namespace'] = namespace - if instance: + if instance is not None: self.properties['instance'] = instance + class EstimoteFilter(DeviceFilter): """Filter for Estimote beacons.""" def __init__(self, identifier=None, protocol_version=None): """Initialize filter.""" super(EstimoteFilter, self).__init__() - if not identifier and not protocol_version: + if identifier is None and protocol_version is None: raise ValueError("EstimoteFilter needs at least one argument set") - if identifier: + if identifier is not None: self.properties['identifier'] = identifier - if protocol_version: + if protocol_version is not None: self.properties['protocol_version'] = protocol_version + class BtAddrFilter(DeviceFilter): """Filter by bluetooth address.""" def __init__(self, bt_addr): """Initialize filter.""" super(BtAddrFilter, self).__init__() - if not bt_addr or len(bt_addr) != 17: - raise ValueError("Invalid bluetooth given, need to be in format aa:bb:cc:dd:ee:ff") + try: + bt_addr = bt_addr.lower() + except AttributeError: + raise ValueError("bt_addr({}) wasn't a string".format(bt_addr)) + if not is_valid_mac(bt_addr): + raise ValueError("Invalid bluetooth MAC address given," + " format should match aa:bb:cc:dd:ee:ff") self.properties['bt_addr'] = bt_addr diff --git a/beacontools/utils.py b/beacontools/utils.py index f6c770d..ba7fdf4 100644 --- a/beacontools/utils.py +++ b/beacontools/utils.py @@ -1,24 +1,37 @@ """Utilities for byte conversion.""" from binascii import hexlify +from re import compile as compile_regex import array import struct from .const import ScannerMode -from .device_filters import IBeaconFilter, EddystoneFilter, BtAddrFilter, EstimoteFilter + +# compiled regex to match lowercase MAC-addresses coming from +# bt_addr_to_string +RE_MAC_ADDR = compile_regex('(?:[0-9a-f]{2}:){5}(?:[0-9a-f]{2})') + + +def is_valid_mac(mac): + """"Returns True if the given argument matches RE_MAC_ADDR, otherwise False""" + return RE_MAC_ADDR.match(mac) is not None + 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() + def bt_addr_to_string(addr): """Convert a binary string to the hex representation.""" addr_str = array.array('B', addr) @@ -27,6 +40,7 @@ def bt_addr_to_string(addr): # insert ":" seperator between the bytes return ':'.join(a+b for a, b in zip(hex_str[::2], hex_str[1::2])) + def is_one_of(obj, types): """Return true iff obj is an instance of one of the types.""" for type_ in types: @@ -34,6 +48,7 @@ def is_one_of(obj, types): return True return False + def is_packet_type(cls): """Check if class is one the packet types.""" from .packet_types import EddystoneUIDFrame, EddystoneURLFrame, \ @@ -44,6 +59,7 @@ def is_packet_type(cls): EddystoneTLMFrame, EddystoneEIDFrame, IBeaconAdvertisement, \ EstimoteTelemetryFrameA, EstimoteTelemetryFrameB]) + def to_int(string): """Convert a one element byte string to int for python 2 support.""" if isinstance(string, str): @@ -51,6 +67,7 @@ def to_int(string): 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): @@ -58,8 +75,10 @@ def bin_to_int(string): else: return struct.unpack("b", bytes([string]))[0] + def get_mode(device_filter): """Determine which beacons the scanner should look for.""" + from .device_filters import IBeaconFilter, EddystoneFilter, BtAddrFilter, EstimoteFilter if device_filter is None or len(device_filter) == 0: return ScannerMode.MODE_ALL From 3072f0d57cbf70e8c264d7d1010cd786eb4be1a4 Mon Sep 17 00:00:00 2001 From: Kai Oliver Quambusch Date: Thu, 13 Dec 2018 01:03:52 +0100 Subject: [PATCH 2/7] fixes Issue #27 --- beacontools/const.py | 25 +++++++++++++ beacontools/scanner.py | 79 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/beacontools/const.py b/beacontools/const.py index 26027d1..e9e0f13 100644 --- a/beacontools/const.py +++ b/beacontools/const.py @@ -1,6 +1,7 @@ """Constants.""" from enum import IntEnum + # for scanner class ScannerMode(IntEnum): """Used to determine which packets should be parsed by the scanner.""" @@ -10,8 +11,32 @@ class ScannerMode(IntEnum): MODE_ESTIMOTE = 4 MODE_ALL = MODE_IBEACON | MODE_EDDYSTONE | MODE_ESTIMOTE + +# hci le scan parameters +class ScanType(IntEnum): + """Determines which type of scan should be executed.""" + PASSIVE = 0x00 + ACTIVE = 0x01 + + +class ScanFilter(IntEnum): + """Determines if only white-listed MAC addresses will be filtered or not""" + ALL = 0x00 + WHITELIST_ONLY = 0x01 + + +class BluetoothAddressType(IntEnum): + """Determines the scanner MAC-address""" + PUBLIC = 0x00 # with device MAC-address + RANDOM = 0x01 # with a random MAC-address + + +# used for window and interval (i.e. 0x10 * 0.625 = 10ms, 10ms / 0.625 = 0x10) +MS_FRACTION_DIVIDER = 0.625 + LE_META_EVENT = 0x3e OGF_LE_CTL = 0x08 +OCF_LE_SET_SCAN_PARAMETERS = 0x000B OCF_LE_SET_SCAN_ENABLE = 0x000C EVT_LE_ADVERTISING_REPORT = 0x02 diff --git a/beacontools/scanner.py b/beacontools/scanner.py index 6d38ada..1330d5e 100644 --- a/beacontools/scanner.py +++ b/beacontools/scanner.py @@ -1,29 +1,32 @@ """Classes responsible for Beacon scanning.""" import threading +import struct import logging from importlib import import_module from .parser import parse_packet from .utils import bt_addr_to_string -from .packet_types import EddystoneUIDFrame, EddystoneURLFrame, \ - EddystoneEncryptedTLMFrame, EddystoneTLMFrame, \ - EddystoneEIDFrame +from .packet_types import (EddystoneUIDFrame, EddystoneURLFrame, + EddystoneEncryptedTLMFrame, EddystoneTLMFrame, + EddystoneEIDFrame,) from .device_filters import BtAddrFilter, DeviceFilter from .utils import is_packet_type, is_one_of, to_int, bin_to_int, get_mode -from .const import ScannerMode, LE_META_EVENT, OGF_LE_CTL, \ - OCF_LE_SET_SCAN_ENABLE, EVT_LE_ADVERTISING_REPORT +from .const import (ScannerMode, ScanType, ScanFilter, BluetoothAddressType, + LE_META_EVENT, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE, + OCF_LE_SET_SCAN_PARAMETERS, EVT_LE_ADVERTISING_REPORT, + MS_FRACTION_DIVIDER,) _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) -# pylint: disable=no-member +# pylint: disable=no-member,too-many-arguments + class BeaconScanner(object): """Scan for Beacon advertisements.""" def __init__(self, callback, bt_device_id=0, device_filter=None, packet_filter=None): - """Initialize scanner.""" # check if device filters are valid if device_filter is not None: @@ -57,6 +60,7 @@ def stop(self): """Stop beacon scanning.""" self._mon.terminate() + class Monitor(threading.Thread): """Continously scan for BLE advertisements.""" @@ -91,6 +95,7 @@ def run(self): self.bluez.hci_filter_set_ptype(filtr, self.bluez.HCI_EVENT_PKT) self.socket.setsockopt(self.bluez.SOL_HCI, self.bluez.HCI_FILTER, filtr) + self.set_scan_parameters() self.toggle_scan(True) while self.keep_going: @@ -100,13 +105,59 @@ def run(self): if event == LE_META_EVENT and subevent == EVT_LE_ADVERTISING_REPORT: # we have an BLE advertisement self.process_packet(pkt) - - def toggle_scan(self, enable): - """Enable and disable BLE scanning.""" - if enable: - command = "\x01\x00" - else: - command = "\x00\x00" + self.socket.close() + + def set_scan_parameters(self, scan_type=ScanType.ACTIVE, interval_ms=10, window_ms=10, + address_type=BluetoothAddressType.RANDOM, filter_type=ScanFilter.ALL): + """"sets the le scan parameters + + Args: + scan_type: ScanType.(PASSIVE|ACTIVE) + interval: ms (as float) between scans (valid range 2.5ms - 10240ms) + ..note:: when interval and window are equal, the scan + runs continuos + window: ms (as float) scan duration (valid range 2.5ms - 10240ms) + address_type: Bluetooth address type BluetoothAddressType.(PUBLIC|RANDOM) + * PUBLIC = use device MAC address + * RANDOM = generate a random MAC address and use that + filter: ScanFilter.(ALL|WHITELIST_ONLY) only ALL is supported, which will + return all fetched bluetooth packets (WHITELIST_ONLY is not supported, + because OCF_LE_ADD_DEVICE_TO_WHITE_LIST command is not implemented) + + Raises: + ValueError: A value had an unexpected format or was not in range + """ + interval_fractions = interval_ms / MS_FRACTION_DIVIDER + if interval_fractions < 0x0004 or interval_fractions > 0x4000: + raise ValueError( + "Invalid interval given {}, must be in range of 2.5ms to 10240ms!".format( + interval_fractions)) + window_fractions = window_ms / MS_FRACTION_DIVIDER + if window_fractions < 0x0004 or window_fractions > 0x4000: + raise ValueError( + "Invalid window given {}, must be in range of 2.5ms to 10240ms!".format( + window_fractions)) + + interval_fractions, window_fractions = int(interval_fractions), int(window_fractions) + + scan_parameter_pkg = struct.pack( + ">BHHBB", + scan_type, + interval_fractions, + window_fractions, + address_type, + filter_type) + self.bluez.hci_send_cmd(self.socket, OGF_LE_CTL, OCF_LE_SET_SCAN_PARAMETERS, + scan_parameter_pkg) + + def toggle_scan(self, enable, filter_duplicates=False): + """Enables or disables BLE scanning + + Args: + enable: boolean value to enable (True) or disable (False) scanner + filter_duplicates: boolean value to enable/disable filter, that + omits duplicated packets""" + command = struct.pack(">BB", enable, filter_duplicates) self.bluez.hci_send_cmd(self.socket, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE, command) def process_packet(self, pkt): From 9611ac7e7472d95033ea89eccdd516a5cae8de94 Mon Sep 17 00:00:00 2001 From: Kai Oliver Quambusch Date: Thu, 13 Dec 2018 01:04:03 +0100 Subject: [PATCH 3/7] linter --- pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylintrc b/pylintrc index 096d51c..2481dac 100644 --- a/pylintrc +++ b/pylintrc @@ -1,6 +1,6 @@ [MASTER] reports=no -disable=too-many-instance-attributes,too-few-public-methods,too-many-branches,locally-disabled,fixme,too-many-boolean-expressions,no-else-return,len-as-condition,inconsistent-return-statements,useless-object-inheritance +disable=cyclic-import,too-many-instance-attributes,too-few-public-methods,too-many-branches,locally-disabled,fixme,too-many-boolean-expressions,no-else-return,len-as-condition,inconsistent-return-statements,useless-object-inheritance max-line-length=100 From 8d5e70bcf0ee6fa8c2930d24f6cb77e5cf4d7bc2 Mon Sep 17 00:00:00 2001 From: Kai Oliver Quambusch Date: Thu, 13 Dec 2018 01:04:18 +0100 Subject: [PATCH 4/7] test for scanner --- tests/test_scanner.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 26e8337..27b0075 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -170,6 +170,17 @@ def test_process_packet_filter_bad(self): scanner._mon.process_packet(pkt) callback.assert_not_called() + def test_repr_filter(self): + self.assertEqual(BtAddrFilter("aa:bb:cc:dd:ee:ff").__repr__(), "BtAddrFilter(bt_addr=aa:bb:cc:dd:ee:ff)") + + def test_wrong_btaddr(self): + self.assertRaises(ValueError, BtAddrFilter, "az") + self.assertRaises(ValueError, BtAddrFilter, None) + self.assertRaises(ValueError, BtAddrFilter, "aa-bb-cc-dd-ee-fg") + self.assertRaises(ValueError, BtAddrFilter, "aa-bb-cc-dd-ee-ff") + self.assertRaises(ValueError, BtAddrFilter, "aabb.ccdd.eeff") + self.assertRaises(ValueError, BtAddrFilter, "aa:bb:cc:dd:ee:") + def test_process_packet_btaddr(self): """Test processing of a packet and callback execution with bt addr filter.""" callback = MagicMock() From 611ae44abe4c7d47e41a5c4bfe0668dd4fd19a4d Mon Sep 17 00:00:00 2001 From: Felix Seele Date: Fri, 4 Jan 2019 21:52:32 +0100 Subject: [PATCH 5/7] updated version --- README.rst | 7 +++++-- setup.py | 9 +++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 0eaef84..3367b03 100644 --- a/README.rst +++ b/README.rst @@ -95,19 +95,22 @@ Changelog --------- Beacontools follows the `semantic versioning `__ scheme. +* 1.3.1 + * Multiple fixes and internal refactorings, including support for Raspberry Pi 3B+ (huge thanks to `cereal `__) + * Updated dependencies * 1.3.0 * Added support for Estimote Telemetry packets (see examples/parser_example.py) * Relaxed parsing constraints for RFU and flags field * Added temperature output in 8.8 fixed point decimal format for Eddystone TLM * 1.2.4 * Added support for Eddystone packets with Flags Data set to 0x1a (thanks to `AndreasTornes `__) - * Updated depedencies + * Updated dependencies * 1.2.3 * Fixed compatibility with construct >=2.9.41 * 1.2.2 * Moved import of bluez so that the library can be used in parsing-only mode, without having bluez installed. * 1.2.1 - * Updated depedencies + * Updated dependencies * 1.2.0 * Added support for Cypress iBeacons which transmit temp and humidity embedded in the minor value (thanks to `darkskiez `__) * Updated dependencies diff --git a/setup.py b/setup.py index 74e5d33..b1c633d 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup( name='beacontools', - version='1.3.0', + version='1.3.1', description='A Python library for working with various types of Bluetooth LE Beacons.', long_description=long_description, @@ -38,6 +38,7 @@ 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', ], keywords='beacons ibeacon eddystone bluetooth low energy ble', @@ -65,9 +66,9 @@ 'scan': ['PyBluez==0.22'], 'dev': ['check-manifest'], 'test': [ - 'coveralls==1.4.0', - 'pytest==3.7.2', - 'pytest-cov==2.5.1', + 'coveralls==1.5.1', + 'pytest==4.0.2', + 'pytest-cov==2.6.0', 'mock==2.0.0', 'check-manifest==0.37', 'pylint', From 435c2a70e143805b1e9d4f7ed8230abc6a263da6 Mon Sep 17 00:00:00 2001 From: Felix Seele Date: Fri, 4 Jan 2019 21:53:09 +0100 Subject: [PATCH 6/7] added python 3.7 to CI --- .travis.yml | 6 ++++-- tox.ini | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1b9d692..3fce2a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,8 @@ sudo: required matrix: fast_finish: true include: + - python: "3.6" + env: TOXENV=lint - python: "2.7.13" env: TOXENV=py27 - python: "3.4" @@ -11,8 +13,8 @@ matrix: env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 - - python: "3.6" - env: TOXENV=lint + - python: "3.7" + env: TOXENV=py37 cache: directories: diff --git a/tox.ini b/tox.ini index 52e7314..83cc4b3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{27,34,35,36}, lint +envlist = py{27,34,35,36,37}, lint skip_missing_interpreters = True [testenv] @@ -8,6 +8,7 @@ basepython = py34: python3.4 py35: python3.5 py36: python3.6 + py37: python3.7 setenv = PYTHONPATH = {toxinidir}:{toxinidir}/beacontools extras = scan From 1d3bd6fed71b8799fbcc102aeadca814b958879e Mon Sep 17 00:00:00 2001 From: Felix Seele Date: Fri, 4 Jan 2019 21:58:31 +0100 Subject: [PATCH 7/7] use ubuntu 16.04 for travis builds --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 3fce2a0..45535df 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ sudo: required +dist: xenial matrix: fast_finish: true