Skip to content

Commit

Permalink
Merge pull request #32 from citruz/release1.3.1
Browse files Browse the repository at this point in the history
Release1.3.1
  • Loading branch information
citruz authored Jan 4, 2019
2 parents bf14608 + 1d3bd6f commit 7276e47
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 37 deletions.
7 changes: 5 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
sudo: required
dist: xenial

matrix:
fast_finish: true
include:
- python: "3.6"
env: TOXENV=lint
- python: "2.7.13"
env: TOXENV=py27
- python: "3.4"
Expand All @@ -11,8 +14,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:
Expand Down
7 changes: 5 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,22 @@ Changelog
---------
Beacontools follows the `semantic versioning <https://semver.org/>`__ scheme.

* 1.3.1
* Multiple fixes and internal refactorings, including support for Raspberry Pi 3B+ (huge thanks to `cereal <https://github.com/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 <https://github.com/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 <https://github.com/darkskiez>`__)
* Updated dependencies
Expand Down
1 change: 1 addition & 0 deletions beacontools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions beacontools/const.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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

Expand Down
41 changes: 29 additions & 12 deletions beacontools/device_filters.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand All @@ -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
79 changes: 65 additions & 14 deletions beacontools/scanner.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -57,6 +60,7 @@ def stop(self):
"""Stop beacon scanning."""
self._mon.terminate()


class Monitor(threading.Thread):
"""Continously scan for BLE advertisements."""

Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
21 changes: 20 additions & 1 deletion beacontools/utils.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -27,13 +40,15 @@ 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:
if isinstance(obj, type_):
return True
return False


def is_packet_type(cls):
"""Check if class is one the packet types."""
from .packet_types import EddystoneUIDFrame, EddystoneURLFrame, \
Expand All @@ -44,22 +59,26 @@ 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):
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]


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

Expand Down
2 changes: 1 addition & 1 deletion pylintrc
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 7276e47

Please sign in to comment.