├── tests ├── __init__.py ├── test_utils.py ├── test_parser.py └── test_scanner.py ├── beacontools ├── structs │ ├── __init__.py │ ├── exposure_notification.py │ ├── ibeacon.py │ ├── controlj.py │ ├── estimote.py │ ├── eddystone.py │ └── common.py ├── backend │ ├── __init__.py │ ├── linux.py │ └── freebsd.py ├── packet_types │ ├── __init__.py │ ├── exposure_notification.py │ ├── ibeacon.py │ ├── controlj.py │ ├── eddystone.py │ └── estimote.py ├── __init__.py ├── const.py ├── parser.py ├── device_filters.py ├── utils.py └── scanner.py ├── setup.cfg ├── MANIFEST.in ├── .gitignore ├── pylintrc ├── examples ├── scanner_cjmonitor_example.py ├── scanner_estimote_stickers_example.py ├── scanner_exposure_notification.py ├── scanner_cypress_beacon_example.py ├── scanner_estimote_example.py ├── scanner_ibeacon_example.py ├── scanner_eddystone_example.py └── parser_example.py ├── CONTRIBUTORS.md ├── tox.ini ├── .travis.yml ├── LICENSE.txt ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for beacontools.""" 2 | -------------------------------------------------------------------------------- /beacontools/structs/__init__.py: -------------------------------------------------------------------------------- 1 | """Packets supported by the parser.""" 2 | from .common import LTVFrame 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [tool:pytest] 5 | testpaths = tests 6 | norecursedirs = .git testing_config 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the license file and contributors 2 | include LICENSE.txt 3 | include CONTRIBUTORS.md 4 | 5 | recursive-include beacontools *.py 6 | 7 | recursive-include examples *.py 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # general things to ignore 2 | build/ 3 | dist/ 4 | .idea 5 | *.egg-info/ 6 | *.egg 7 | *.py[cod] 8 | __pycache__/ 9 | *.so 10 | *~ 11 | .DS_Store 12 | venv/ 13 | venv27/ 14 | 15 | # due to using tox and pytest 16 | .coverage 17 | .tox 18 | .cache 19 | -------------------------------------------------------------------------------- /beacontools/backend/__init__.py: -------------------------------------------------------------------------------- 1 | """Load a different backend depending on the OS.""" 2 | import sys 3 | 4 | if sys.platform.startswith("linux"): 5 | from .linux import * 6 | elif sys.platform.startswith("freebsd"): 7 | from .freebsd import * 8 | else: 9 | raise NotImplementedError("Scanning not supported on this platform") 10 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | reports=no 3 | 4 | disable=cyclic-import,too-many-instance-attributes, 5 | too-few-public-methods,too-many-branches,locally-disabled, 6 | fixme,too-many-boolean-expressions,no-else-return, 7 | len-as-condition,inconsistent-return-statements, 8 | useless-object-inheritance,too-many-arguments 9 | 10 | max-line-length=120 11 | -------------------------------------------------------------------------------- /examples/scanner_cjmonitor_example.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from beacontools import BeaconScanner, CJMonitorFilter 4 | 5 | 6 | def callback(bt_addr, rssi, packet, additional_info): 7 | print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info)) 8 | 9 | 10 | # scan for all CJ Monitor advertisements 11 | scanner = BeaconScanner(callback, device_filter=CJMonitorFilter()) 12 | scanner.start() 13 | time.sleep(5) 14 | scanner.stop() 15 | -------------------------------------------------------------------------------- /examples/scanner_estimote_stickers_example.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from beacontools import BeaconScanner, EstimoteFilter 4 | 5 | 6 | def callback(bt_addr, rssi, packet, additional_info): 7 | print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info)) 8 | 9 | scanner = BeaconScanner(callback, 10 | device_filter=EstimoteFilter(identifier="1efe427eb6f4bc2f") 11 | ) 12 | scanner.start() 13 | time.sleep(10) 14 | scanner.stop() 15 | -------------------------------------------------------------------------------- /beacontools/structs/exposure_notification.py: -------------------------------------------------------------------------------- 1 | """All low level structures used for parsing COVID-19 exposure notifications.""" 2 | from construct import Array, Byte, Struct 3 | 4 | # pylint: disable=invalid-name 5 | 6 | # see https://blog.google/documents/70/Exposure_Notification_-_Bluetooth_Specification_v1.2.2.pdf 7 | 8 | ExposureNotificationFrame = Struct( 9 | "identifier" / Array(16, Byte), 10 | "encrypted_metadata" / Array(4, Byte), 11 | ) 12 | -------------------------------------------------------------------------------- /beacontools/structs/ibeacon.py: -------------------------------------------------------------------------------- 1 | """All low level structures used for parsing ibeacon packets.""" 2 | from construct import Struct, Byte, Const, Int8sl, Array, Int16ub 3 | from ..const import IBEACON_PROXIMITY_TYPE 4 | 5 | # pylint: disable=invalid-name 6 | 7 | IBeaconMSD = Struct( 8 | "beacon_type" / Const(IBEACON_PROXIMITY_TYPE), 9 | "uuid" / Array(16, Byte), 10 | "major" / Int16ub, 11 | "minor" / Int16ub, 12 | "tx_power" / Int8sl, 13 | ) 14 | -------------------------------------------------------------------------------- /beacontools/packet_types/__init__.py: -------------------------------------------------------------------------------- 1 | """Packets supported by the parser.""" 2 | from .eddystone import EddystoneUIDFrame, EddystoneURLFrame, EddystoneEncryptedTLMFrame, \ 3 | EddystoneTLMFrame, EddystoneEIDFrame 4 | from .ibeacon import IBeaconAdvertisement 5 | from .estimote import EstimoteTelemetryFrameA, EstimoteTelemetryFrameB, EstimoteNearable 6 | from .controlj import CJMonitorAdvertisement 7 | from .exposure_notification import ExposureNotificationFrame 8 | -------------------------------------------------------------------------------- /examples/scanner_exposure_notification.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from beacontools import BeaconScanner, ExposureNotificationFrame 4 | 5 | def callback(bt_addr, rssi, packet, additional_info): 6 | print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info)) 7 | 8 | # scan for all COVID-19 exposure notifications 9 | scanner = BeaconScanner(callback, 10 | packet_filter=[ExposureNotificationFrame] 11 | ) 12 | scanner.start() 13 | time.sleep(5) 14 | scanner.stop() 15 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Many thanks to everyone who contributed to this project: 2 | (in chronological order) 3 | 4 | - miek (https://github.com/miek) 5 | - marcosmoreno (https://github.com/marcosmoreno) 6 | - darkskiez (https://github.com/darkskiez) Google LLC 7 | - cereal (https://github.com/cereal) 8 | - shaun (https://github.com/ShaunPlummer) 9 | - clydebarrow (https://github.com/clydebarrow) 10 | - myfreeweb (https://github.com/myfreeweb) 11 | - cleitonbueno (https://github.com/cleitonbueno) 12 | - idaniel86 (https://github.com/idaniel86) 13 | -------------------------------------------------------------------------------- /beacontools/structs/controlj.py: -------------------------------------------------------------------------------- 1 | """All low level structures used for parsing control-j Monitor packets.""" 2 | from construct import Struct, Int8ul, Int16ul, Switch 3 | 4 | from ..const import CJ_TEMPHUM_TYPE 5 | 6 | # pylint: disable=invalid-name 7 | 8 | CJMonitorTempHum = Struct( 9 | "temperature" / Int16ul, 10 | "humidity" / Int8ul, 11 | "light" / Int8ul, 12 | ) 13 | 14 | CJMonitorMSD = Struct( 15 | "beacon_type" / Int16ul, 16 | "data" / Switch(lambda ctx: ctx.beacon_type, { 17 | CJ_TEMPHUM_TYPE: CJMonitorTempHum, 18 | }), 19 | ) 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{36,37,38}, lint 3 | skip_missing_interpreters = True 4 | 5 | [testenv] 6 | basepython = 7 | py36: python3.6 8 | py37: python3.7 9 | py38: python3.8 10 | setenv = PYTHONPATH = {toxinidir}:{toxinidir}/beacontools 11 | extras = 12 | scan 13 | test 14 | commands = 15 | check-manifest --ignore tox.ini,pylintrc,requirements_test.txt,tests/* 16 | py.test --cov beacontools 17 | 18 | [testenv:lint] 19 | basepython = python3 20 | ignore_errors = True 21 | commands = 22 | pylint beacontools 23 | python setup.py check -m -r -s 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: bionic 3 | 4 | matrix: 5 | fast_finish: true 6 | include: 7 | - python: "3.6" 8 | env: TOXENV=lint 9 | # py3.6 build keeps failing, I have no idea why 10 | #- python: "3.6" 11 | # env: TOXENV=py36 12 | - python: "3.7" 13 | env: TOXENV=py37 14 | - python: "3.8" 15 | env: TOXENV=py38 16 | 17 | cache: 18 | directories: 19 | - $HOME/.cache/pip 20 | before_install: 21 | - sudo apt-get -qq update 22 | - sudo apt-get install -y libbluetooth-dev 23 | install: pip install -U tox coveralls 24 | language: python 25 | script: tox 26 | after_success: coveralls -------------------------------------------------------------------------------- /examples/scanner_cypress_beacon_example.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from beacontools import BeaconScanner, IBeaconFilter, CYPRESS_BEACON_DEFAULT_UUID 4 | 5 | def callback(bt_addr, rssi, packet, additional_info): 6 | print("<%s, %d> Major:%s %.1fdegC %.1f %%RH" % ( 7 | bt_addr, rssi, packet.major, packet.cypress_temperature, packet.cypress_humidity)) 8 | 9 | # scan for all iBeacon advertisements from beacons with the specified uuid 10 | scanner = BeaconScanner(callback, 11 | device_filter=IBeaconFilter(uuid=CYPRESS_BEACON_DEFAULT_UUID) 12 | ) 13 | scanner.start() 14 | # Cypress beacons by default transmit every 5 minutes 15 | time.sleep(6*60) 16 | scanner.stop() 17 | -------------------------------------------------------------------------------- /examples/scanner_estimote_example.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from beacontools import BeaconScanner, EstimoteTelemetryFrameA, EstimoteTelemetryFrameB, EstimoteFilter 4 | 5 | def callback(bt_addr, rssi, packet, additional_info): 6 | print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info)) 7 | 8 | # scan for all Estimote telemetry packets from a specific beacon 9 | scanner = BeaconScanner(callback, 10 | packet_filter=[EstimoteTelemetryFrameA, EstimoteTelemetryFrameB], 11 | # remove the following line to see packets from all beacons 12 | device_filter=EstimoteFilter(identifier="47a038d5eb032640") 13 | ) 14 | scanner.start() 15 | time.sleep(10) 16 | scanner.stop() 17 | -------------------------------------------------------------------------------- /examples/scanner_ibeacon_example.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from beacontools import BeaconScanner, IBeaconFilter, IBeaconAdvertisement 4 | 5 | def callback(bt_addr, rssi, packet, additional_info): 6 | print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info)) 7 | 8 | # scan for all iBeacon advertisements from beacons with certain properties: 9 | # - uuid 10 | # - major 11 | # - minor 12 | # at least one must be specified. 13 | scanner = BeaconScanner(callback, 14 | device_filter=IBeaconFilter(uuid="e5b9e3a6-27e2-4c36-a257-7698da5fc140") 15 | ) 16 | scanner.start() 17 | time.sleep(5) 18 | scanner.stop() 19 | 20 | # scan for all iBeacon advertisements regardless from which beacon 21 | scanner = BeaconScanner(callback, 22 | packet_filter=IBeaconAdvertisement 23 | ) 24 | scanner.start() 25 | time.sleep(5) 26 | scanner.stop() 27 | -------------------------------------------------------------------------------- /examples/scanner_eddystone_example.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from beacontools import BeaconScanner, EddystoneTLMFrame, EddystoneFilter, \ 4 | EddystoneUIDFrame, EddystoneURLFrame 5 | 6 | def callback(bt_addr, rssi, packet, additional_info): 7 | print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info)) 8 | 9 | # scan for all TLM frames of beacons in the namespace "12345678901234678901" 10 | scanner = BeaconScanner( 11 | callback, 12 | device_filter=EddystoneFilter(namespace="12345678901234678901"), 13 | packet_filter=[EddystoneTLMFrame, EddystoneUIDFrame] 14 | ) 15 | scanner.start() 16 | time.sleep(10) 17 | scanner.stop() 18 | 19 | # scan for all URL frames without filtering for a specific beacon 20 | scanner = BeaconScanner( 21 | callback, 22 | packet_filter=EddystoneURLFrame 23 | ) 24 | scanner.start() 25 | time.sleep(10) 26 | scanner.stop() 27 | -------------------------------------------------------------------------------- /beacontools/backend/linux.py: -------------------------------------------------------------------------------- 1 | """Backend for Linux using bluez""" 2 | from bluetooth import _bluetooth as bluez 3 | 4 | # pylint: disable=c-extension-no-member 5 | 6 | def open_dev(bt_device_id): 7 | """Open hci device socket.""" 8 | socket = bluez.hci_open_dev(bt_device_id) 9 | 10 | filtr = bluez.hci_filter_new() 11 | bluez.hci_filter_all_events(filtr) 12 | bluez.hci_filter_set_ptype(filtr, bluez.HCI_EVENT_PKT) 13 | socket.setsockopt(bluez.SOL_HCI, bluez.HCI_FILTER, filtr) 14 | 15 | return socket 16 | 17 | def send_cmd(socket, group_field, command_field, data): 18 | """Send hci command to device.""" 19 | return bluez.hci_send_cmd(socket, group_field, command_field, data) 20 | 21 | def send_req(socket, group_field, command_field, event, rlen, params, timeout): 22 | """Send hci request to device.""" 23 | return bluez.hci_send_req(socket, group_field, command_field, event, rlen, params, timeout) 24 | -------------------------------------------------------------------------------- /beacontools/__init__.py: -------------------------------------------------------------------------------- 1 | """A library for working with various types of Bluetooth LE Beacons..""" 2 | from .const import CYPRESS_BEACON_DEFAULT_UUID, BluetoothAddressType, ScanFilter, ScanType 3 | from .scanner import BeaconScanner 4 | from .parser import parse_packet 5 | from .packet_types.eddystone import EddystoneUIDFrame, EddystoneURLFrame, \ 6 | EddystoneEncryptedTLMFrame, EddystoneTLMFrame, \ 7 | EddystoneEIDFrame 8 | from .packet_types.ibeacon import IBeaconAdvertisement 9 | from .packet_types.controlj import CJMonitorAdvertisement 10 | from .packet_types.estimote import EstimoteTelemetryFrameA, EstimoteTelemetryFrameB 11 | from .packet_types.exposure_notification import ExposureNotificationFrame 12 | from .device_filters import IBeaconFilter, EddystoneFilter, BtAddrFilter, EstimoteFilter, \ 13 | CJMonitorFilter, ExposureNotificationFilter 14 | from .utils import is_valid_mac 15 | -------------------------------------------------------------------------------- /beacontools/packet_types/exposure_notification.py: -------------------------------------------------------------------------------- 1 | """Packet classes for Eddystone beacons.""" 2 | from ..utils import data_to_hexstring, data_to_binstring 3 | 4 | class ExposureNotificationFrame(object): 5 | """COVID-19 Exposure Notification frame.""" 6 | 7 | def __init__(self, data): 8 | self._identifier = data_to_hexstring(data['identifier']) 9 | self._encrypted_metadata = data_to_binstring(data['encrypted_metadata']) 10 | 11 | @property 12 | def identifier(self): 13 | """16 byte Rolling Proximity Identifier""" 14 | return self._identifier 15 | 16 | @property 17 | def encrypted_metadata(self): 18 | """4 byte encrypted data containing version info and transmission power""" 19 | return self._encrypted_metadata 20 | 21 | @property 22 | def properties(self): 23 | """Get beacon properties.""" 24 | return {'identifier': self.identifier, 'encrypted_metadata' : self.encrypted_metadata} 25 | 26 | def __str__(self): 27 | return "ExposureNotificationFrame" % (self.identifier) 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2017 Felix Seele and contributors listed in CONTRIBUTORS.md 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /beacontools/structs/estimote.py: -------------------------------------------------------------------------------- 1 | """All low level structures used for parsing Estimote packets.""" 2 | from construct import Struct, Byte, Switch, Int8sl, Array, Int8ul, Const, Int16ul 3 | 4 | from ..const import ESTIMOTE_TELEMETRY_SUBFRAME_A, ESTIMOTE_TELEMETRY_SUBFRAME_B, \ 5 | ESTIMOTE_NEARABLE_FRAME 6 | 7 | # pylint: disable=invalid-name 8 | 9 | EstimoteTelemetrySubFrameA = Struct( 10 | "acceleration" / Array(3, Int8sl), 11 | "previous_motion" / Byte, 12 | "current_motion" / Byte, 13 | "combined_fields" / Array(5, Byte), 14 | ) 15 | 16 | EstimoteTelemetrySubFrameB = Struct( 17 | "magnetic_field" / Array(3, Int8sl), 18 | "ambient_light" / Int8ul, 19 | "combined_fields" / Array(5, Byte), 20 | "battery_level" / Int8ul, 21 | ) 22 | 23 | EstimoteTelemetryFrame = Struct( 24 | "identifier" / Array(8, Byte), 25 | "subframe_type" / Byte, 26 | "sub_frame" / Switch(lambda ctx: ctx.subframe_type, { 27 | ESTIMOTE_TELEMETRY_SUBFRAME_A: EstimoteTelemetrySubFrameA, 28 | ESTIMOTE_TELEMETRY_SUBFRAME_B: EstimoteTelemetrySubFrameB, 29 | }) 30 | ) 31 | 32 | EstimoteNearableFrame = Struct( 33 | Const(ESTIMOTE_NEARABLE_FRAME), 34 | "identifier" / Array(8, Byte), 35 | "hardware_version" / Int8ul, 36 | "firmware_version" / Int8ul, 37 | "temperature" / Int16ul, 38 | "is_moving" / Int8ul, 39 | ) 40 | -------------------------------------------------------------------------------- /beacontools/structs/eddystone.py: -------------------------------------------------------------------------------- 1 | """All low level structures used for parsing eddystone packets.""" 2 | from construct import Struct, Byte, Switch, OneOf, Int8sl, Array, \ 3 | GreedyString, Int16ub, Int16ul, Int32ub 4 | 5 | from ..const import EDDYSTONE_URL_SCHEMES, EDDYSTONE_TLM_UNENCRYPTED, EDDYSTONE_TLM_ENCRYPTED 6 | 7 | # pylint: disable=invalid-name 8 | 9 | EddystoneUIDFrame = Struct( 10 | "tx_power" / Int8sl, 11 | "namespace" / Array(10, Byte), 12 | "instance" / Array(6, Byte), 13 | # commented out because it is not used anyway and there seem to be beacons which 14 | # don't send it at all (see https://github.com/citruz/beacontools/issues/39) 15 | # "rfu" / Array(2, Byte) 16 | ) 17 | 18 | EddystoneURLFrame = Struct( 19 | "tx_power" / Int8sl, 20 | "url_scheme" / OneOf(Byte, list(EDDYSTONE_URL_SCHEMES)), 21 | "url" / GreedyString(encoding="ascii") 22 | ) 23 | 24 | UnencryptedTLMFrame = Struct( 25 | "voltage" / Int16ub, 26 | "temperature" / Int16ub, 27 | "advertising_count" / Int32ub, 28 | "seconds_since_boot" / Int32ub, 29 | ) 30 | 31 | EncryptedTLMFrame = Struct( 32 | "encrypted_data" / Array(12, Byte), 33 | "salt" / Int16ul, 34 | "mic" / Int16ul 35 | ) 36 | 37 | EddystoneTLMFrame = Struct( 38 | "tlm_version" / Byte, 39 | "data" / Switch(lambda ctx: ctx.tlm_version, { 40 | EDDYSTONE_TLM_UNENCRYPTED: UnencryptedTLMFrame, 41 | EDDYSTONE_TLM_ENCRYPTED: EncryptedTLMFrame, 42 | }) 43 | ) 44 | 45 | EddystoneEIDFrame = Struct( 46 | "tx_power" / Int8sl, 47 | "eid" / Array(8, Byte) 48 | ) 49 | -------------------------------------------------------------------------------- /beacontools/packet_types/ibeacon.py: -------------------------------------------------------------------------------- 1 | """Packet classes for iBeacon beacons.""" 2 | from ..utils import data_to_uuid 3 | 4 | class IBeaconAdvertisement(object): 5 | """iBeacon advertisement.""" 6 | 7 | def __init__(self, data): 8 | self._uuid = data_to_uuid(data['uuid']) 9 | self._major = data['major'] 10 | self._minor = data['minor'] 11 | self._tx_power = data['tx_power'] 12 | 13 | @property 14 | def tx_power(self): 15 | """Calibrated Tx power at 0 m.""" 16 | return self._tx_power 17 | 18 | @property 19 | def uuid(self): 20 | """16-byte uuid.""" 21 | return self._uuid 22 | 23 | @property 24 | def major(self): 25 | """2-byte major identifier.""" 26 | return self._major 27 | 28 | @property 29 | def minor(self): 30 | """2-byte minor identifier.""" 31 | return self._minor 32 | 33 | @property 34 | def cypress_temperature(self): 35 | """Cypress iBeacon Sensor temperature in C.""" 36 | return 175.72*((self._minor & 0xff)*256)/65536 - 46.85 37 | 38 | @property 39 | def cypress_humidity(self): 40 | """Cypress iBeacon Sensor humidity RH%.""" 41 | return 125.0*(self._minor & 0xff00)/65536 - 6 42 | 43 | @property 44 | def properties(self): 45 | """Get beacon properties.""" 46 | return {'uuid': self.uuid, 'major': self.major, 'minor': self.minor} 47 | 48 | def __str__(self): 49 | return "IBeaconAdvertisement" \ 50 | % (self.tx_power, self.uuid, self.major, self.minor) 51 | -------------------------------------------------------------------------------- /beacontools/packet_types/controlj.py: -------------------------------------------------------------------------------- 1 | """Packet classes for Control-J Monitors.""" 2 | from ..utils import mulaw_to_value, data_to_binstring 3 | from ..const import MANUFACTURER_SPECIFIC_DATA_TYPE, CJ_TEMPHUM_TYPE, COMPLETE_LOCALE_NAME_DATA_TYPE 4 | 5 | class CJMonitorAdvertisement(object): 6 | """CJ Monitor advertisement.""" 7 | 8 | def __init__(self, frame): 9 | 10 | for ltv in frame: 11 | if ltv['type'] == MANUFACTURER_SPECIFIC_DATA_TYPE: 12 | msd = ltv['value'] 13 | self._company_id = msd['company_identifier'] 14 | self._beacon_type = msd['data']['beacon_type'] 15 | if self._beacon_type == CJ_TEMPHUM_TYPE: 16 | data = msd['data']['data'] 17 | self._temperature = data['temperature'] / 100.0 18 | self._humidity = data['humidity'] 19 | self._light = mulaw_to_value(data['light']) / 10.0 20 | elif ltv['type'] == COMPLETE_LOCALE_NAME_DATA_TYPE: 21 | self._name = data_to_binstring(ltv['value']).decode("ascii") 22 | 23 | @property 24 | def name(self): 25 | """device name""" 26 | return self._name 27 | 28 | @property 29 | def humidity(self): 30 | """humidity in %""" 31 | return self._humidity 32 | 33 | @property 34 | def company_id(self): 35 | """company ID""" 36 | return self._company_id 37 | 38 | @property 39 | def beacon_type(self): 40 | """type of this beacon""" 41 | return self._beacon_type 42 | 43 | @property 44 | def temperature(self): 45 | """temperature in C.""" 46 | return self._temperature 47 | 48 | @property 49 | def light(self): 50 | """light level in lux""" 51 | return self._light 52 | 53 | @property 54 | def properties(self): 55 | """Get Monitor properties.""" 56 | return {'name': self.name, 57 | 'temperature': self.temperature, 58 | 'humidity': self.humidity, 59 | 'light': self.light, 60 | 'company_id': self.company_id, 61 | 'beacon_type': self.beacon_type} 62 | 63 | def __str__(self): 64 | return "CJMonitorAdvertisement".format( 66 | name=self.name, temperature=self.temperature, 67 | humidity=self.humidity, light=self.light) 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup module for beacontools.""" 2 | 3 | from setuptools import setup, find_packages 4 | from codecs import open 5 | from os import path 6 | import sys 7 | 8 | here = path.abspath(path.dirname(__file__)) 9 | 10 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 11 | long_description = f.read() 12 | 13 | setup( 14 | name='beacontools', 15 | 16 | version='2.1.0', 17 | 18 | description='A Python library for working with various types of Bluetooth LE Beacons.', 19 | long_description=long_description, 20 | 21 | url='https://github.com/citruz/beacontools', 22 | 23 | author='Felix Seele', 24 | author_email='fseele@gmail.com', 25 | 26 | license='MIT', 27 | 28 | classifiers=[ 29 | 'Development Status :: 5 - Production/Stable', 30 | 31 | 'Intended Audience :: Developers', 32 | 'Topic :: Software Development :: Libraries', 33 | 34 | 'License :: OSI Approved :: MIT License', 35 | 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.5', 38 | 'Programming Language :: Python :: 3.6', 39 | 'Programming Language :: Python :: 3.7', 40 | ], 41 | 42 | keywords='beacons ibeacon eddystone bluetooth low energy ble', 43 | 44 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 45 | 46 | # Alternatively, if you want to distribute just a my_module.py, uncomment 47 | # this: 48 | # py_modules=["my_module"], 49 | 50 | # List run-time dependencies here. These will be installed by pip when 51 | # your project is installed. For an analysis of "install_requires" vs pip's 52 | # requirements files see: 53 | # https://packaging.python.org/en/latest/requirements.html 54 | install_requires=[ 55 | 'construct>=2.9.52,<2.11', 56 | 'ahocorapy==1.6.1' 57 | ], 58 | 59 | # List additional groups of dependencies here (e.g. development 60 | # dependencies). You can install these using the following syntax, 61 | # for example: 62 | # $ pip install -e .[dev,test] 63 | extras_require={ 64 | 'scan': ['PyBluez==0.23'] if sys.platform.startswith("linux") else [], 65 | 'dev': ['check-manifest'], 66 | 'test': [ 67 | 'coveralls~=2.1', 68 | 'pytest~=6.0', 69 | 'pytest-cov~=2.10', 70 | 'mock~=4.0', 71 | 'check-manifest', 72 | 'pylint', 73 | 'readme_renderer', 74 | 'docutils' 75 | ], 76 | }, 77 | ) 78 | -------------------------------------------------------------------------------- /beacontools/backend/freebsd.py: -------------------------------------------------------------------------------- 1 | """Custom backend for FreeBSD.""" 2 | import os 3 | import socket 4 | import struct 5 | import ctypes 6 | 7 | libc = ctypes.cdll.LoadLibrary('libc.so.7') 8 | 9 | NG_HCI_EVENT_MASK_LE = 0x2000000000000000 10 | SOL_HCI_RAW = 0x0802 11 | SOL_HCI_RAW_FILTER = 1 12 | 13 | class SockaddrHci(ctypes.Structure): 14 | """Structure representing a hci socket address.""" 15 | _fields_ = [ 16 | ('hci_len', ctypes.c_char), 17 | ('hci_family', ctypes.c_char), 18 | ('hci_node', ctypes.c_char * 32), 19 | ] 20 | 21 | class HciRawFilter(ctypes.Structure): 22 | """Structure specifying filter masks.""" 23 | _fields_ = [ 24 | ('packet_mask', ctypes.c_uint32), 25 | ('event_mask', ctypes.c_uint64), 26 | ] 27 | 28 | def open_dev(bt_device_id): 29 | """Open hci device socket.""" 30 | # pylint: disable=no-member 31 | sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_RAW, socket.BTPROTO_HCI) 32 | 33 | # Unlike Linux, FreeBSD has separate numbering depending on hardware 34 | # (ubt - USB bluetooth - is the most common, so convert numbers to that) 35 | if not isinstance(bt_device_id, str): 36 | bt_device_id = 'ubt{}hci'.format(bt_device_id) 37 | 38 | # Python's BTPROTO_HCI address parsing is busted: https://bugs.python.org/issue41130 39 | adr = SockaddrHci(ctypes.sizeof(SockaddrHci), socket.AF_BLUETOOTH, bt_device_id.ljust(32, '\0').encode('utf-8')) 40 | if libc.bind(sock.fileno(), ctypes.pointer(adr), ctypes.sizeof(SockaddrHci)) != 0: 41 | raise ConnectionError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) 42 | if libc.connect(sock.fileno(), ctypes.pointer(adr), ctypes.sizeof(SockaddrHci)) != 0: 43 | raise ConnectionError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) 44 | # pylint: enable=no-member 45 | 46 | fltr = HciRawFilter(0, NG_HCI_EVENT_MASK_LE) 47 | if libc.setsockopt(sock.fileno(), 48 | SOL_HCI_RAW, SOL_HCI_RAW_FILTER, 49 | ctypes.pointer(fltr), ctypes.sizeof(HciRawFilter)) != 0: 50 | raise ConnectionError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) 51 | 52 | return sock 53 | 54 | def send_cmd(sock, group_field, command_field, data): 55 | """Send hci command to device.""" 56 | opcode = (((group_field & 0x3f) << 10) | (command_field & 0x3ff)) 57 | sock.send(struct.pack('> 4 81 | if data['frame']['subframe_type'] == ESTIMOTE_TELEMETRY_SUBFRAME_A: 82 | return EstimoteTelemetryFrameA(data['frame'], protocol_version) 83 | elif data['frame']['subframe_type'] == ESTIMOTE_TELEMETRY_SUBFRAME_B: 84 | return EstimoteTelemetryFrameB(data['frame'], protocol_version) 85 | return None 86 | -------------------------------------------------------------------------------- /beacontools/device_filters.py: -------------------------------------------------------------------------------- 1 | """Filters passed to the BeaconScanner to filter results.""" 2 | from .const import CJ_MANUFACTURER_ID, CJ_TEMPHUM_TYPE 3 | from .utils import is_valid_mac 4 | 5 | 6 | class DeviceFilter(object): 7 | """Base class for all device filters. Should not be used by itself.""" 8 | 9 | def __init__(self): 10 | """Initialize filter.""" 11 | self.properties = {} 12 | 13 | def matches(self, filter_props): 14 | """Check if the filter matches the supplied properties.""" 15 | if filter_props is None: 16 | return False 17 | 18 | found_one = False 19 | for key, value in filter_props.items(): 20 | if key in self.properties and value != self.properties[key]: 21 | return False 22 | elif key in self.properties and value == self.properties[key]: 23 | found_one = True 24 | 25 | return found_one 26 | 27 | def __repr__(self): 28 | return "{}({})".format( 29 | self.__class__.__name__, 30 | ", ".join(["=".join((k, str(v),)) for k, v in self.properties.items()])) 31 | 32 | 33 | class CJMonitorFilter(DeviceFilter): 34 | """Filter for CJ Monitor.""" 35 | 36 | def __init__(self, company_id=CJ_MANUFACTURER_ID, beacon_type=CJ_TEMPHUM_TYPE): 37 | """Initialize filter.""" 38 | super().__init__() 39 | if company_id is None and beacon_type is None: 40 | raise ValueError("CJMonitorFilter needs at least one argument set") 41 | if company_id is not None: 42 | self.properties['company_id'] = company_id 43 | if beacon_type is not None: 44 | self.properties['beacon_type'] = beacon_type 45 | 46 | class IBeaconFilter(DeviceFilter): 47 | """Filter for iBeacon.""" 48 | 49 | def __init__(self, uuid=None, major=None, minor=None): 50 | """Initialize filter.""" 51 | super().__init__() 52 | if uuid is None and major is None and minor is None: 53 | raise ValueError("IBeaconFilter needs at least one argument set") 54 | if uuid is not None: 55 | self.properties['uuid'] = uuid 56 | if major is not None: 57 | self.properties['major'] = major 58 | if minor is not None: 59 | self.properties['minor'] = minor 60 | 61 | 62 | class EddystoneFilter(DeviceFilter): 63 | """Filter for Eddystone beacons.""" 64 | 65 | def __init__(self, namespace=None, instance=None): 66 | """Initialize filter.""" 67 | super().__init__() 68 | if namespace is None and instance is None: 69 | raise ValueError("EddystoneFilter needs at least one argument set") 70 | if namespace is not None: 71 | self.properties['namespace'] = namespace 72 | if instance is not None: 73 | self.properties['instance'] = instance 74 | 75 | 76 | class EstimoteFilter(DeviceFilter): 77 | """Filter for Estimote beacons.""" 78 | 79 | def __init__(self, identifier=None, protocol_version=None): 80 | """Initialize filter.""" 81 | super().__init__() 82 | if identifier is None and protocol_version is None: 83 | raise ValueError("EstimoteFilter needs at least one argument set") 84 | if identifier is not None: 85 | self.properties['identifier'] = identifier 86 | if protocol_version is not None: 87 | self.properties['protocol_version'] = protocol_version 88 | 89 | 90 | class ExposureNotificationFilter(DeviceFilter): 91 | """Filter for specific exposure notification identifier.""" 92 | 93 | def __init__(self, identifier): 94 | """Initialize filter.""" 95 | super().__init__() 96 | if identifier is None: 97 | raise ValueError("ExposureNotificationFilter needs identifier to be set") 98 | self.properties['identifier'] = identifier 99 | 100 | 101 | class BtAddrFilter(DeviceFilter): 102 | """Filter by bluetooth address.""" 103 | 104 | def __init__(self, bt_addr): 105 | """Initialize filter.""" 106 | super().__init__() 107 | try: 108 | bt_addr = bt_addr.lower() 109 | except AttributeError as exc: 110 | raise ValueError("bt_addr({}) wasn't a string".format(bt_addr)) from exc 111 | if not is_valid_mac(bt_addr): 112 | raise ValueError("Invalid bluetooth MAC address given," 113 | " format should match aa:bb:cc:dd:ee:ff") 114 | self.properties['bt_addr'] = bt_addr 115 | -------------------------------------------------------------------------------- /beacontools/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for byte conversion.""" 2 | from binascii import hexlify 3 | from re import compile as compile_regex 4 | import array 5 | import struct 6 | 7 | from .const import ScannerMode 8 | 9 | # compiled regex to match lowercase MAC-addresses coming from 10 | # bt_addr_to_string 11 | RE_MAC_ADDR = compile_regex('(?:[0-9a-f]{2}:){5}(?:[0-9a-f]{2})') 12 | 13 | 14 | def is_valid_mac(mac): 15 | """"Returns True if the given argument matches RE_MAC_ADDR, otherwise False""" 16 | return RE_MAC_ADDR.match(mac) is not None 17 | 18 | 19 | def data_to_hexstring(data): 20 | """Convert an array of binary data to the hex representation as a string.""" 21 | return hexlify(data_to_binstring(data)).decode('ascii') 22 | 23 | 24 | def data_to_uuid(data): 25 | """Convert an array of binary data to the iBeacon uuid format.""" 26 | string = data_to_hexstring(data) 27 | return string[0:8]+'-'+string[8:12]+'-'+string[12:16]+'-'+string[16:20]+'-'+string[20:32] 28 | 29 | 30 | def data_to_binstring(data): 31 | """Convert an array of binary data to a binary string.""" 32 | return array.array('B', data).tobytes() 33 | 34 | def mulaw_to_value(mudata): 35 | """Convert a mu-law encoded value to linear.""" 36 | position = ((mudata & 0xF0) >> 4) + 5 37 | return ((1 << position) | ((mudata & 0xF) << (position - 4)) | (1 << (position - 5))) - 33 38 | 39 | def bt_addr_to_string(addr): 40 | """Convert a binary string to the hex representation.""" 41 | addr_str = array.array('B', addr) 42 | addr_str.reverse() 43 | hex_str = hexlify(addr_str.tobytes()).decode('ascii') 44 | # insert ":" seperator between the bytes 45 | return ':'.join(a+b for a, b in zip(hex_str[::2], hex_str[1::2])) 46 | 47 | 48 | def is_one_of(obj, types): 49 | """Return true iff obj is an instance of one of the types.""" 50 | for type_ in types: 51 | if isinstance(obj, type_): 52 | return True 53 | return False 54 | 55 | 56 | def is_packet_type(cls): 57 | """Check if class is one the packet types.""" 58 | # pylint: disable=import-outside-toplevel 59 | from .packet_types import EddystoneUIDFrame, EddystoneURLFrame, \ 60 | EddystoneEncryptedTLMFrame, EddystoneTLMFrame, \ 61 | EddystoneEIDFrame, IBeaconAdvertisement, \ 62 | EstimoteTelemetryFrameA, EstimoteTelemetryFrameB, \ 63 | ExposureNotificationFrame 64 | # pylint: enable=import-outside-toplevel 65 | 66 | return (cls in [EddystoneURLFrame, EddystoneUIDFrame, EddystoneEncryptedTLMFrame, \ 67 | EddystoneTLMFrame, EddystoneEIDFrame, IBeaconAdvertisement, \ 68 | EstimoteTelemetryFrameA, EstimoteTelemetryFrameB, \ 69 | ExposureNotificationFrame]) 70 | 71 | 72 | def to_int(string): 73 | """Convert a one element byte string to int for python 2 support.""" 74 | if isinstance(string, str): 75 | return ord(string[0]) 76 | else: 77 | return string 78 | 79 | 80 | def bin_to_int(string): 81 | """Convert a one element byte string to signed int for python 2 support.""" 82 | if isinstance(string, str): 83 | return struct.unpack("b", string)[0] 84 | else: 85 | return struct.unpack("b", bytes([string]))[0] 86 | 87 | 88 | def get_mode(device_filter): 89 | """Determine which beacons the scanner should look for.""" 90 | from .device_filters import IBeaconFilter, EddystoneFilter, BtAddrFilter, EstimoteFilter, \ 91 | CJMonitorFilter, ExposureNotificationFilter # pylint: disable=import-outside-toplevel 92 | 93 | if device_filter is None or len(device_filter) == 0: 94 | return ScannerMode.MODE_ALL 95 | 96 | mode = ScannerMode.MODE_NONE 97 | for filtr in device_filter: 98 | if isinstance(filtr, IBeaconFilter): 99 | mode |= ScannerMode.MODE_IBEACON 100 | elif isinstance(filtr, EddystoneFilter): 101 | mode |= ScannerMode.MODE_EDDYSTONE 102 | elif isinstance(filtr, EstimoteFilter): 103 | mode |= ScannerMode.MODE_ESTIMOTE 104 | elif isinstance(filtr, CJMonitorFilter): 105 | mode |= ScannerMode.MODE_CJMONITOR 106 | elif isinstance(filtr, ExposureNotificationFilter): 107 | mode |= ScannerMode.MODE_EXPOSURE_NOTIFICATION 108 | elif isinstance(filtr, BtAddrFilter): 109 | mode |= ScannerMode.MODE_ALL 110 | break 111 | 112 | return mode 113 | -------------------------------------------------------------------------------- /beacontools/packet_types/eddystone.py: -------------------------------------------------------------------------------- 1 | """Packet classes for Eddystone beacons.""" 2 | from binascii import hexlify 3 | from ..const import EDDYSTONE_URL_SCHEMES, EDDYSTONE_TLD_ENCODINGS 4 | from ..utils import data_to_hexstring, data_to_binstring 5 | 6 | class EddystoneUIDFrame(object): 7 | """Eddystone UID frame.""" 8 | 9 | def __init__(self, data): 10 | self._tx_power = data['tx_power'] 11 | self._namespace = data_to_hexstring(data['namespace']) 12 | self._instance = data_to_hexstring(data['instance']) 13 | 14 | @property 15 | def tx_power(self): 16 | """Calibrated Tx power at 0 m.""" 17 | return self._tx_power 18 | 19 | @property 20 | def namespace(self): 21 | """10-byte namespace identifier.""" 22 | return self._namespace 23 | 24 | @property 25 | def instance(self): 26 | """6-byte instance identifier.""" 27 | return self._instance 28 | 29 | @property 30 | def properties(self): 31 | """Get beacon properties.""" 32 | return {'namespace': self.namespace, 'instance': self.instance} 33 | 34 | def __str__(self): 35 | return "EddystoneUIDFrame" \ 36 | % (self.tx_power, self.namespace, self.instance) 37 | 38 | 39 | class EddystoneURLFrame(object): 40 | """Eddystone URL frame.""" 41 | 42 | def __init__(self, data): 43 | self._tx_power = data['tx_power'] 44 | url_scheme = EDDYSTONE_URL_SCHEMES[data['url_scheme']] 45 | url = data['url'] 46 | 47 | # Replace url encodings with their expanded version 48 | for enc, tld in EDDYSTONE_TLD_ENCODINGS.items(): 49 | url = url.replace(chr(enc), tld) 50 | 51 | self._url = url_scheme + url 52 | 53 | @property 54 | def tx_power(self): 55 | """Calibrated Tx power at 0 m.""" 56 | return self._tx_power 57 | 58 | @property 59 | def url(self): 60 | """Transmitted URL.""" 61 | return self._url 62 | 63 | def __str__(self): 64 | return "EddystoneURLFrame" \ 65 | % (self.tx_power, self.url) 66 | 67 | 68 | class EddystoneEncryptedTLMFrame(object): 69 | """Eddystone encrypted TLM frame.""" 70 | 71 | def __init__(self, data): 72 | self._encrypted_data = data_to_binstring(data['encrypted_data']) 73 | self._salt = data['salt'] 74 | self._mic = data['mic'] 75 | 76 | @property 77 | def encrypted_data(self): 78 | """Encrypted TLM data.""" 79 | return self._encrypted_data 80 | 81 | @property 82 | def salt(self): 83 | """16-bit salt.""" 84 | return self._salt 85 | 86 | @property 87 | def mic(self): 88 | """16-bit message integrity check.""" 89 | return self._mic 90 | 91 | def __str__(self): 92 | return "EddystoneEncryptedTLMFrame" \ 93 | % (hexlify(self.encrypted_data), self.salt, self.mic) 94 | 95 | 96 | class EddystoneTLMFrame(object): 97 | """Eddystone TLM frame.""" 98 | 99 | def __init__(self, data): 100 | self._voltage = data['voltage'] 101 | self._temperature = data['temperature'] / float(256) 102 | self._advertising_count = data['advertising_count'] 103 | self._seconds_since_boot = data['seconds_since_boot'] 104 | 105 | @property 106 | def voltage(self): 107 | """Battery voltage measured in mV.""" 108 | return self._voltage 109 | 110 | @property 111 | def temperature(self): 112 | """Temperature in degree Celsius.""" 113 | return self._temperature 114 | 115 | @property 116 | def advertising_count(self): 117 | """Advertising PDU count.""" 118 | return self._advertising_count 119 | 120 | @property 121 | def seconds_since_boot(self): 122 | """Time since power-on or reboot.""" 123 | return self._seconds_since_boot 124 | 125 | def __str__(self): 126 | return "EddystoneTLMFrame" % (self.voltage, self.temperature, \ 128 | self.advertising_count, self.seconds_since_boot) 129 | 130 | class EddystoneEIDFrame(object): 131 | """Eddystone EID frame.""" 132 | 133 | def __init__(self, data): 134 | self._tx_power = data['tx_power'] 135 | self._eid = data_to_binstring(data['eid']) 136 | 137 | @property 138 | def tx_power(self): 139 | """Calibrated Tx power at 0 m.""" 140 | return self._tx_power 141 | 142 | @property 143 | def eid(self): 144 | """8-byte Ephemeral Identifier.""" 145 | return self._eid 146 | 147 | def __str__(self): 148 | return "EddystoneEIDFrame" \ 149 | % (self.tx_power, hexlify(self.eid)) 150 | -------------------------------------------------------------------------------- /examples/parser_example.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from beacontools import parse_packet 3 | 4 | # Eddystone UID packet 5 | 6 | uid_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x17\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12" \ 7 | b"\x34\x67\x89\x01\x00\x00\x00\x00\x00\x01\x00\x00" 8 | uid_frame = parse_packet(uid_packet) 9 | print("Namespace: %s" % uid_frame.namespace) 10 | print("Instance: %s" % uid_frame.instance) 11 | print("TX Power: %s" % uid_frame.tx_power) 12 | 13 | print("-----") 14 | 15 | # Eddystone URL packet 16 | url_packet = b"\x03\x03\xAA\xFE\x13\x16\xAA\xFE\x10\xF8\x03github\x00citruz" 17 | url_frame = parse_packet(url_packet) 18 | print("TX Power: %d" % url_frame.tx_power) 19 | print("URL: %s" % url_frame.url) 20 | 21 | print("-----") 22 | 23 | # Eddystone TLM packet (unencrypted) 24 | tlm_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x11\x16\xaa\xfe\x20\x00\x0b\x18\x13\x00\x00\x00" \ 25 | b"\x14\x67\x00\x00\x2a\xc4\xe4" 26 | tlm_frame = parse_packet(tlm_packet) 27 | print("Voltage: %d mV" % tlm_frame.voltage) 28 | print("Temperature: %f °C" % tlm_frame.temperature) 29 | print("Advertising count: %d" % tlm_frame.advertising_count) 30 | print("Seconds since boot: %d" % tlm_frame.seconds_since_boot) 31 | 32 | print("-----") 33 | 34 | # Eddystone TLM packet (encrypted) 35 | enc_tlm_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x11\x16\xaa\xfe\x20\x01\x41\x41\x41\x41\x41" \ 36 | b"\x41\x41\x41\x41\x41\x41\x41\xDE\xAD\xBE\xFF" 37 | enc_tlm_frame = parse_packet(enc_tlm_packet) 38 | print("Data: %s" % enc_tlm_frame.encrypted_data) 39 | print("Salt: %d" % enc_tlm_frame.salt) 40 | print("Mic: %d" % enc_tlm_frame.mic) 41 | 42 | print("-----") 43 | 44 | # iBeacon Advertisement 45 | ibeacon_packet = b"\x02\x01\x06\x1a\xff\x4c\x00\x02\x15\x41\x41\x41\x41\x41\x41\x41\x41\x41" \ 46 | b"\x41\x41\x41\x41\x41\x41\x41\x00\x01\x00\x01\xf8" 47 | adv = parse_packet(ibeacon_packet) 48 | print("UUID: %s" % adv.uuid) 49 | print("Major: %d" % adv.major) 50 | print("Minor: %d" % adv.minor) 51 | print("TX Power: %d" % adv.tx_power) 52 | 53 | print("-----") 54 | 55 | # Cypress iBeacon Sensor 56 | cypress_packet = b"\x02\x01\x04\x1a\xff\x4c\x00\x02\x15\x00\x05\x00\x01\x00\x00\x10\x00\x80" \ 57 | b"\x00\x00\x80\x5f\x9b\x01\x31\x00\x02\x6c\x66\xc3" 58 | sensor = parse_packet(cypress_packet) 59 | print("UUID: %s" % sensor.uuid) 60 | print("Major: %d" % sensor.major) 61 | print("Temperature: %d °C" % sensor.cypress_temperature) 62 | print("Humidity: %d %%" % sensor.cypress_humidity) 63 | print("TX Power: %d" % sensor.tx_power) 64 | 65 | print("-----") 66 | 67 | # Estimote Telemetry Packet (Subframe A) 68 | telemetry_a_packet = b"\x02\x01\x04\x03\x03\x9a\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5"\ 69 | b"\xeb\x03\x26\x40\x00\x00\x01\x41\x44\x47\xfa\xff\xff\xff\xff" 70 | telemetry = parse_packet(telemetry_a_packet) 71 | print("Identifier: %s" % telemetry.identifier) 72 | print("Protocol Version: %d" % telemetry.protocol_version) 73 | print("Acceleration (g): (%f, %f, %f)" % telemetry.acceleration) 74 | print("Is moving: %s" % telemetry.is_moving) 75 | # ... see packet_types/estimote.py for all available attributes and units 76 | 77 | print("-----") 78 | 79 | # Estimote Telemetry Packet (Subframe B) 80 | telemetry_b_packet = b"\x02\x01\x04\x03\x03\x9a\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5"\ 81 | b"\xeb\x03\x26\x40\x01\xd8\x42\xed\x73\x49\x25\x66\xbc\x2e\x50" 82 | telemetry_b = parse_packet(telemetry_b_packet) 83 | print("Identifier: %s" % telemetry_b.identifier) 84 | print("Protocol Version: %d" % telemetry_b.protocol_version) 85 | print("Magnetic field: (%f, %f, %f)" % telemetry_b.magnetic_field) 86 | print("Temperature: %f °C" % telemetry_b.temperature) 87 | # ... see packet_types/estimote.py for all available attributes and units 88 | 89 | # Estimote Nearable Advertisement 90 | nearable_packet = b"\x02\x01\x04\x03\x03\x0f\x18\x17\xff\x5d" \ 91 | b"\x01\x01\x1e\xfe\x42\x7e\xb6\xf4\xbc\x2f" \ 92 | b"\x04\x01\x68\xa1\xaa\xfe\x05\xc1\x45\x25" \ 93 | b"\x53\xb5" 94 | nearable_adv = parse_packet(nearable_packet) 95 | print("Identifier: %s" % nearable_adv.identifier) 96 | print("Hardware_version: %d" % nearable_adv.hardware_version) 97 | print("Firmware_version: %d" % nearable_adv.firmware_version) 98 | print("Temperature: %d" % nearable_adv.temperature) 99 | print("Is moving: %i" % nearable_adv.is_moving) 100 | 101 | print("-----") 102 | 103 | # CJ Monitor packet 104 | cj_monitor_packet = b"\x02\x01\x06\x05\x02\x1A\x18\x00\x18" \ 105 | b"\x09\xFF\x72\x04\xFE\x10\xD1\x0C\x33\x61" \ 106 | b"\x09\x09\x4D\x6F\x6E\x20\x35\x36\x34\x33" 107 | cj_monitor = parse_packet(cj_monitor_packet) 108 | print("Name: %s" % cj_monitor.name) 109 | print("Temperature: %f °C" % cj_monitor.temperature) 110 | print("Humidity: %d %%" % cj_monitor.humidity) 111 | print("Light: %f" % cj_monitor.light) 112 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | BeaconTools - Universal beacon scanning 2 | ======================================= 3 | |PyPI Package| |Build Status| |Coverage Status| |Requirements Status| 4 | 5 | A Python library for working with various types of Bluetooth LE Beacons. 6 | 7 | Currently supported types are: 8 | 9 | * `Eddystone Beacons `__ 10 | * `iBeacons `__ (Apple and Cypress CYALKIT-E02) 11 | * `Estimote Beacons (Telemetry and Nearable) `__ 12 | * Control-J Monitor (temp/humidity/light) 13 | * `COVID-19 Exposure Notifications `__ 14 | 15 | The BeaconTools library has two main components: 16 | 17 | * a parser to extract information from raw binary beacon advertisements 18 | * a scanner which scans for Bluetoth LE advertisements using bluez and can be configured to look only for specific beacons or packet types 19 | 20 | Installation 21 | ------------ 22 | If you only want to use the **parser** install the library using pip and you're good to go: 23 | 24 | .. code:: bash 25 | 26 | pip3 install beacontools 27 | 28 | If you want to perfom beacon **scanning** there are a few more requirements. 29 | First of all, you need a supported OS: currently that's Linux with BlueZ, and FreeBSD. 30 | Second, you need raw socket access (via Linux capabilities, or by running as root). 31 | 32 | On a typical Linux installation, it would look like this: 33 | 34 | .. code:: bash 35 | 36 | # install libbluetooth headers and libpcap2 37 | sudo apt-get install python3-dev libbluetooth-dev libcap2-bin 38 | # grant the python executable permission to access raw socket data 39 | sudo setcap 'cap_net_raw,cap_net_admin+eip' "$(readlink -f "$(which python3)")" 40 | # install beacontools with scanning support 41 | pip3 install beacontools[scan] 42 | 43 | Usage 44 | ----- 45 | See the `examples `__ directory for more usage examples. 46 | 47 | Parser 48 | ~~~~~~ 49 | 50 | .. code:: python 51 | 52 | from beacontools import parse_packet 53 | 54 | tlm_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x11\x16\xaa\xfe\x20\x00\x0b\x18\x13\x00\x00\x00" \ 55 | b"\x14\x67\x00\x00\x2a\xc4\xe4" 56 | tlm_frame = parse_packet(tlm_packet) 57 | print("Voltage: %d mV" % tlm_frame.voltage) 58 | print("Temperature: %d °C" % tlm_frame.temperature) 59 | print("Advertising count: %d" % tlm_frame.advertising_count) 60 | print("Seconds since boot: %d" % tlm_frame.seconds_since_boot) 61 | 62 | Scanner 63 | ~~~~~~~ 64 | .. code:: python 65 | 66 | import time 67 | 68 | from beacontools import BeaconScanner, EddystoneTLMFrame, EddystoneFilter 69 | 70 | def callback(bt_addr, rssi, packet, additional_info): 71 | print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info)) 72 | 73 | # scan for all TLM frames of beacons in the namespace "12345678901234678901" 74 | scanner = BeaconScanner(callback, 75 | # remove the following line to see packets from all beacons 76 | device_filter=EddystoneFilter(namespace="12345678901234678901"), 77 | packet_filter=EddystoneTLMFrame 78 | ) 79 | scanner.start() 80 | time.sleep(10) 81 | scanner.stop() 82 | 83 | 84 | 85 | .. code:: python 86 | 87 | import time 88 | from beacontools import BeaconScanner, IBeaconFilter 89 | 90 | def callback(bt_addr, rssi, packet, additional_info): 91 | print("<%s, %d> %s %s" % (bt_addr, rssi, packet, additional_info)) 92 | 93 | # scan for all iBeacon advertisements from beacons with the specified uuid 94 | scanner = BeaconScanner(callback, 95 | device_filter=IBeaconFilter(uuid="e5b9e3a6-27e2-4c36-a257-7698da5fc140") 96 | ) 97 | scanner.start() 98 | time.sleep(5) 99 | scanner.stop() 100 | 101 | # scan for all iBeacon advertisements regardless from which beacon 102 | scanner = BeaconScanner(callback, 103 | packet_filter=IBeaconAdvertisement 104 | ) 105 | scanner.start() 106 | time.sleep(5) 107 | scanner.stop() 108 | 109 | 110 | Customizing Scanning Parameters 111 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 112 | Some Bluetooth dongle don't allow scanning in Randomized MAC mode. If you don't receive any scan results, try setting the scan mode to PUBLIC: 113 | 114 | .. code:: python 115 | 116 | from beacontools import BeaconScanner, BluetoothAddressType 117 | 118 | scanner = BeaconScanner( 119 | callback, 120 | scan_parameters={"address_type": BluetoothAddressType.PUBLIC} 121 | ) 122 | 123 | For all available options see ``Monitor.set_scan_parameters``. 124 | 125 | Changelog 126 | --------- 127 | Beacontools follows the `semantic versioning `__ scheme. 128 | 129 | * 2.1.0 130 | * Added support for extended BLE commands for devices using HCI >= 5.0 (Linux only, thanks to `idaniel86 `__) 131 | * 2.0.2 132 | * Improved prefiltering of packets, fixes #48 133 | * 2.0.1 134 | * Removed (unused) rfu field from the Eddystone UID packet, fixes #39 135 | * 2.0.0 136 | * Dropped support for Python 2.7 and 3.4 137 | * Added support for COVID-19 Exposure Notifications 138 | * Added support for FreeBSD and fixed temperature parsing (thanks to `myfreeweb `__) 139 | * Added support for Control-J Monitor beacons (thanks to `clydebarrow `__) 140 | * Added support for Estimote Nearables (thanks to `ShaunPlummer `__) 141 | * 1.3.1 142 | * Multiple fixes and internal refactorings, including support for Raspberry Pi 3B+ (huge thanks to `cereal `__) 143 | * Updated dependencies 144 | * 1.3.0 145 | * Added support for Estimote Telemetry packets (see examples/parser_example.py) 146 | * Relaxed parsing constraints for RFU and flags field 147 | * Added temperature output in 8.8 fixed point decimal format for Eddystone TLM 148 | * 1.2.4 149 | * Added support for Eddystone packets with Flags Data set to 0x1a (thanks to `AndreasTornes `__) 150 | * Updated dependencies 151 | * 1.2.3 152 | * Fixed compatibility with construct >=2.9.41 153 | * 1.2.2 154 | * Moved import of bluez so that the library can be used in parsing-only mode, without having bluez installed. 155 | * 1.2.1 156 | * Updated dependencies 157 | * 1.2.0 158 | * Added support for Cypress iBeacons which transmit temp and humidity embedded in the minor value (thanks to `darkskiez `__) 159 | * Updated dependencies 160 | * 1.1.0 161 | * Added support for Eddystone EID frames (thanks to `miek `__) 162 | * Updated dependencies 163 | * 1.0.1 164 | * Implemented a small tweak which reduces the CPU usage. 165 | * 1.0.0 166 | * Implemented iBeacon support 167 | * Added rssi to callback function. 168 | * 0.1.2 169 | * Initial release 170 | 171 | .. |PyPI Package| image:: https://badge.fury.io/py/beacontools.svg 172 | :target: https://pypi.python.org/pypi/beacontools/ 173 | .. |Build Status| image:: https://travis-ci.org/citruz/beacontools.svg?branch=master 174 | :target: https://travis-ci.org/citruz/beacontools 175 | .. |Coverage Status| image:: https://coveralls.io/repos/github/citruz/beacontools/badge.svg?branch=master 176 | :target: https://coveralls.io/github/citruz/beacontools?branch=master 177 | .. |Requirements Status| image:: https://requires.io/github/citruz/beacontools/requirements.svg?branch=master 178 | :target: https://requires.io/github/citruz/beacontools/requirements/?branch=master 179 | -------------------------------------------------------------------------------- /beacontools/packet_types/estimote.py: -------------------------------------------------------------------------------- 1 | """Packet classes for Estimote beacons.""" 2 | from ..utils import data_to_hexstring 3 | 4 | class EstimoteTelemetryFrameA(object): 5 | """Estimote telemetry subframe A.""" 6 | 7 | def __init__(self, data, protocol_version): 8 | self._protocol_version = protocol_version 9 | self._identifier = data_to_hexstring(data['identifier']) 10 | sub = data['sub_frame'] 11 | # acceleration: convert to tuple and normalize 12 | self._acceleration = tuple([v * 2 / 127.0 for v in sub['acceleration']]) 13 | # motion states 14 | self._previous_motion_state = self.parse_motion_state(sub['previous_motion']) 15 | self._current_motion_state = self.parse_motion_state(sub['current_motion']) 16 | self._is_moving = (sub['combined_fields'][0] & 0b00000011) == 1 17 | # gpio 18 | states = [] 19 | for i in range(4): 20 | states.append((sub['combined_fields'][0] & (1 << (4+i))) != 0) 21 | self._gpio_states = tuple(states) 22 | # error codes 23 | if self.protocol_version == 2: 24 | self._has_firmware_error = ((sub['combined_fields'][0] & 0b00000100) >> 2) == 1 25 | self._has_clock_error = ((sub['combined_fields'][0] & 0b00001000) >> 3) == 1 26 | elif self.protocol_version == 1: 27 | self._has_firmware_error = (sub['combined_fields'][1] & 0b00000001) == 1 28 | self._has_clock_error = ((sub['combined_fields'][1] & 0b00000010) >> 1) == 1 29 | else: 30 | self._has_firmware_error = None 31 | self._has_clock_error = None 32 | # pressure 33 | if self.protocol_version == 2: 34 | self._pressure = sub['combined_fields'][1] | \ 35 | sub['combined_fields'][2] << 8 | \ 36 | sub['combined_fields'][3] << 16 | \ 37 | sub['combined_fields'][4] << 24 38 | if self._pressure == 0xffffffff: 39 | self._pressure = None 40 | else: 41 | self._pressure /= 256.0 42 | else: 43 | self._pressure = None 44 | 45 | @staticmethod 46 | def parse_motion_state(val): 47 | """Convert motion state byte to seconds.""" 48 | number = val & 0b00111111 49 | unit = (val & 0b11000000) >> 6 50 | if unit == 1: 51 | number *= 60 # minutes 52 | elif unit == 2: 53 | number *= 60 * 60 # hours 54 | elif unit == 3 and number < 32: 55 | number *= 60 * 60 * 24 # days 56 | elif unit == 3: 57 | number -= 32 58 | number *= 60 * 60 * 24 * 7 # weeks 59 | return number 60 | 61 | @property 62 | def protocol_version(self): 63 | """Protocol version of the packet.""" 64 | return self._protocol_version 65 | 66 | @property 67 | def identifier(self): 68 | """First half of the identifier of the beacon (8 bytes).""" 69 | return self._identifier 70 | 71 | @property 72 | def acceleration(self): 73 | """Tuple of acceleration values for (X, Y, Z) axis, in g.""" 74 | return self._acceleration 75 | 76 | @property 77 | def is_moving(self): 78 | """Whether the beacon is in motion at the moment (Bool)""" 79 | return self._is_moving 80 | 81 | @property 82 | def current_motion_state(self): 83 | """Duration of current motion state in seconds. 84 | E.g., if is_moving is True, this states how long the beacon is beeing moved already and 85 | previous_motion_state will tell how long it has been still before.""" 86 | return self._current_motion_state 87 | 88 | 89 | @property 90 | def previous_motion_state(self): 91 | """Duration of previous motion state in seconds (see current_motion_state).""" 92 | return self._previous_motion_state 93 | 94 | @property 95 | def gpio_states(self): 96 | """Tuple with state of the GPIO pins 0-3 (True is high, False is low).""" 97 | return self._gpio_states 98 | 99 | @property 100 | def has_firmware_error(self): 101 | """If beacon has a firmware problem. 102 | Only available if protocol version > 0, None otherwise.""" 103 | return self._has_firmware_error 104 | 105 | @property 106 | def has_clock_error(self): 107 | """If beacon has a clock problem. Only available if protocol version > 0, None otherwise.""" 108 | return self._has_clock_error 109 | 110 | @property 111 | def pressure(self): 112 | """Atmosperic pressure in Pascal. None if all bits are set. 113 | Only available if protocol version is 2, None otherwise .""" 114 | return self._pressure 115 | 116 | @property 117 | def properties(self): 118 | """Get beacon properties.""" 119 | return {'identifier': self.identifier, 'protocol_version': self.protocol_version} 120 | 121 | def __str__(self): 122 | return "EstimoteTelemetryFrameA" \ 123 | % (self.identifier, self.protocol_version) 124 | 125 | 126 | class EstimoteTelemetryFrameB(object): 127 | """Estimote telemetry subframe B.""" 128 | 129 | def __init__(self, data, protocol_version): 130 | self._protocol_version = protocol_version 131 | self._identifier = data_to_hexstring(data['identifier']) 132 | sub = data['sub_frame'] 133 | # magnetic field: convert to tuple and normalize 134 | if sub['magnetic_field'] == [-1, -1, -1]: 135 | self._magnetic_field = None 136 | else: 137 | self._magnetic_field = tuple([v / 128.0 for v in sub['magnetic_field']]) 138 | # ambient light 139 | ambient_upper = (sub['ambient_light'] & 0b11110000) >> 4 140 | ambient_lower = sub['ambient_light'] & 0b00001111 141 | if ambient_upper == 0xf and ambient_lower == 0xf: 142 | self._ambient_light = None 143 | else: 144 | self._ambient_light = pow(2, ambient_upper) * ambient_lower * 0.72 145 | # uptime 146 | uptime_unit_code = (sub['combined_fields'][1] & 0b00110000) >> 4 147 | uptime_number = ((sub['combined_fields'][1] & 0b00001111) << 8) | \ 148 | sub['combined_fields'][0] 149 | if uptime_unit_code == 1: 150 | uptime_number *= 60 # minutes 151 | elif uptime_unit_code == 2: 152 | uptime_number *= 60 * 60 # hours 153 | elif uptime_unit_code == 3: 154 | uptime_number *= 60 * 60 * 24 # days 155 | else: 156 | uptime_number = 0 157 | self._uptime = uptime_number 158 | # temperature 159 | temperature = ((sub['combined_fields'][3] & 0b00000011) << 10) | \ 160 | (sub['combined_fields'][2] << 2) | \ 161 | ((sub['combined_fields'][1] & 0b11000000) >> 6) 162 | temperature = temperature - 4096 if temperature > 2047 else temperature 163 | self._temperature = temperature / 16.0 164 | # battery voltage 165 | voltage = (sub['combined_fields'][4] << 6) | \ 166 | ((sub['combined_fields'][3] & 0b11111100) >> 2) 167 | self._voltage = None if voltage == 0b11111111111111 else voltage 168 | if self._protocol_version == 0: 169 | # errors (only protocol ver 0) 170 | self._has_firmware_error = (sub['battery_level'] & 0b00000001) == 1 171 | self._has_clock_error = (sub['battery_level'] & 0b00000010) == 0b10 172 | self._battery_level = None 173 | else: 174 | self._battery_level = None if sub['battery_level'] == 0xFF else sub['battery_level'] 175 | self._has_clock_error = None 176 | self._has_firmware_error = None 177 | 178 | 179 | @property 180 | def protocol_version(self): 181 | """Protocol version of the packet.""" 182 | return self._protocol_version 183 | 184 | @property 185 | def identifier(self): 186 | """First half of the identifier of the beacon (8 bytes).""" 187 | return self._identifier 188 | 189 | @property 190 | def magnetic_field(self): 191 | """Tuple of magnetic field values for (X, Y, Z) axis. 192 | Between -1 and 1 or None if all bits are set.""" 193 | return self._magnetic_field 194 | 195 | @property 196 | def ambient_light(self): 197 | """Ambient light in lux.""" 198 | return self._ambient_light 199 | 200 | @property 201 | def uptime(self): 202 | """Uptime in seconds.""" 203 | return self._uptime 204 | 205 | @property 206 | def temperature(self): 207 | """Ambient temperature in celsius.""" 208 | return self._temperature 209 | 210 | @property 211 | def has_firmware_error(self): 212 | """Whether beacon has a firmware problem. 213 | Only available if protocol version is 0, None otherwise.""" 214 | return self._has_firmware_error 215 | 216 | @property 217 | def has_clock_error(self): 218 | """Whether beacon has a clock problem. 219 | Only available if protocol version is 0, None otherwise.""" 220 | return self._has_clock_error 221 | 222 | @property 223 | def battery_level(self): 224 | """Beacon battery level between 0 and 100. 225 | None if protocol version is 0 or not measured yet.""" 226 | return self._battery_level 227 | 228 | @property 229 | def properties(self): 230 | """Get beacon properties.""" 231 | return {'identifier': self.identifier, 'protocol_version': self.protocol_version} 232 | 233 | def __str__(self): 234 | return "EstimoteTelemetryFrameB" \ 235 | % (self.identifier, self.protocol_version) 236 | 237 | 238 | class EstimoteNearable(object): 239 | """Estimote Nearable advertisement.""" 240 | 241 | def __init__(self, data): 242 | self._identifier = data_to_hexstring(data['identifier']) 243 | self._hardware_version = data['hardware_version'] 244 | self._firmware_version = data['firmware_version'] 245 | 246 | # byte 13 and the first 4 bits of byte 14 is the temperature in signed, 247 | temperature_raw_value = (data['temperature'] & 0x0fff) 248 | if temperature_raw_value > 2047: 249 | # convert a 12-bit unsigned integer to a signed one 250 | temperature_raw_value = temperature_raw_value - 4096 251 | temperature = temperature_raw_value / 16.0 252 | self._temperature = temperature 253 | self._is_moving = data['is_moving'] & 0b01000000 != 0 254 | 255 | @property 256 | def identifier(self): 257 | """The Nearable identifier (8 bytes).""" 258 | return self._identifier 259 | 260 | @property 261 | def hardware_version(self): 262 | """The hardware version of the nearable.""" 263 | return self._hardware_version 264 | 265 | @property 266 | def firmware_version(self): 267 | """The firmware version of the nearable.""" 268 | return self._firmware_version 269 | 270 | @property 271 | def temperature(self): 272 | """The temperature reading taken by the nearable.""" 273 | return self._temperature 274 | 275 | @property 276 | def is_moving(self): 277 | """Whether the beacon is in motion at the moment.""" 278 | return self._is_moving 279 | 280 | @property 281 | def properties(self): 282 | """Get beacon properties.""" 283 | return {'identifier': self.identifier, 'temperature': self.temperature, 284 | 'is_moving': self._is_moving} 285 | 286 | def __str__(self): 287 | return "EstimoteNearable" \ 288 | % self.identifier 289 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | """Test the parser component.""" 2 | import unittest 3 | 4 | from beacontools import parse_packet, EddystoneUIDFrame, EddystoneURLFrame, \ 5 | EddystoneEncryptedTLMFrame, EddystoneTLMFrame, EddystoneEIDFrame, \ 6 | IBeaconAdvertisement, EstimoteTelemetryFrameA, EstimoteTelemetryFrameB, \ 7 | ExposureNotificationFrame 8 | from beacontools.packet_types import EstimoteNearable 9 | 10 | class TestParser(unittest.TestCase): 11 | """Test the parser.""" 12 | 13 | def test_bad_packets(self): 14 | """Test if random data results in a None result.""" 15 | tests = [ 16 | b"0000000", 17 | b"", 18 | b"\x02\x01\x06\x03\x03", 19 | b"\x12\x34\x67\x89\x01\x00\x00\x00\x00\x00\x01\x00\x00", 20 | b"\x02\x01\x06\x03\x03\xaa\xfe\x17\x16\xaa\xfe\x01\xe3\x12\x34\x56\x78\x90" \ 21 | b"\x12\x34\x67\x89\x01\x00\x00\x00\x00\x00\x01\x00\x00" 22 | ] 23 | 24 | for test in tests: 25 | frame = parse_packet(test) 26 | self.assertIsNone(frame) 27 | 28 | 29 | def test_eddystone_uid(self): 30 | """Test UID frame.""" 31 | uid_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x17\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90" \ 32 | b"\x12\x34\x67\x89\x01\x00\x00\x00\x00\x00\x01\x00\x00" 33 | 34 | frame = parse_packet(uid_packet) 35 | self.assertIsInstance(frame, EddystoneUIDFrame) 36 | self.assertEqual(frame.namespace, "12345678901234678901") 37 | self.assertEqual(frame.instance, "000000000001") 38 | self.assertEqual(frame.tx_power, -29) 39 | self.assertEqual(frame.properties, { 40 | "namespace":"12345678901234678901", 41 | "instance":"000000000001", 42 | }) 43 | self.assertIsNotNone(str(frame)) 44 | 45 | def test_eddystone_url(self): 46 | """Test URL frame.""" 47 | url_packet = b"\x03\x03\xAA\xFE\x13\x16\xAA\xFE\x10\xF8\x03github\x00citruz" 48 | 49 | frame = parse_packet(url_packet) 50 | self.assertIsInstance(frame, EddystoneURLFrame) 51 | self.assertEqual(frame.url, "https://github.com/citruz") 52 | self.assertEqual(frame.tx_power, -8) 53 | self.assertIsNotNone(str(frame)) 54 | 55 | def test_eddystone_tlm(self): 56 | """Test TLM frame.""" 57 | tlm_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x11\x16\xaa\xfe\x20\x00\x0b\x18\x13\x00\x00" \ 58 | b"\x00\x14\x67\x00\x00\x2a\xc4\xe4" 59 | frame = parse_packet(tlm_packet) 60 | self.assertIsInstance(frame, EddystoneTLMFrame) 61 | self.assertEqual(frame.voltage, 2840) 62 | self.assertEqual(frame.temperature, 19) 63 | self.assertEqual(frame.advertising_count, 5223) 64 | self.assertEqual(frame.seconds_since_boot, 10948) 65 | self.assertIsNotNone(str(frame)) 66 | 67 | def test_eddystone_tlm2(self): 68 | """Test TLM frame.""" 69 | tlm_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x11\x16\xaa\xfe\x20\x00\x0b\x18\x47\x11\x00" \ 70 | b"\x00\x14\x67\x00\x00\x2a\xc4\xe4" 71 | frame = parse_packet(tlm_packet) 72 | self.assertIsInstance(frame, EddystoneTLMFrame) 73 | self.assertEqual(frame.voltage, 2840) 74 | self.assertTrue(abs(frame.temperature - 71) < 0.1) 75 | self.assertEqual(frame.advertising_count, 5223) 76 | self.assertEqual(frame.seconds_since_boot, 10948) 77 | self.assertIsNotNone(str(frame)) 78 | 79 | def test_eddystone_tlm_enc(self): 80 | """Test encrypted TLM frame.""" 81 | enc_tlm_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x11\x16\xaa\xfe\x20\x01\x41\x41\x41" \ 82 | b"\x41\x41\x41\x41\x41\x41\x41\x41\x41\xDE\xAD\xBE\xFF" 83 | frame = parse_packet(enc_tlm_packet) 84 | self.assertIsInstance(frame, EddystoneEncryptedTLMFrame) 85 | self.assertEqual(frame.encrypted_data, b'AAAAAAAAAAAA') 86 | self.assertEqual(frame.salt, 44510) 87 | self.assertEqual(frame.mic, 65470) 88 | self.assertIsNotNone(str(frame)) 89 | 90 | def test_eddystone_eid(self): 91 | """Test EID frame.""" 92 | eid_packet = b"\x02\x01\x06\x03\x03\xaa\xfe\x0d\x16\xaa\xfe\x30\xe3" \ 93 | b"\x45\x49\x44\x5f\x74\x65\x73\x74" 94 | frame = parse_packet(eid_packet) 95 | self.assertIsInstance(frame, EddystoneEIDFrame) 96 | self.assertEqual(frame.tx_power, -29) 97 | self.assertEqual(frame.eid, b'EID_test') 98 | self.assertIsNotNone(str(frame)) 99 | 100 | def test_ibeacon(self): 101 | """Test iBeacon advertisement.""" 102 | ibeacon_packet = b"\x02\x01\x06\x1a\xff\x4c\x00\x02\x15\x41\x42\x43\x44\x45\x46\x47\x48"\ 103 | b"\x49\x40\x41\x42\x43\x44\x45\x46\x00\x01\x00\x02\xf8" 104 | frame = parse_packet(ibeacon_packet) 105 | self.assertIsInstance(frame, IBeaconAdvertisement) 106 | self.assertEqual(frame.uuid, "41424344-4546-4748-4940-414243444546") 107 | self.assertEqual(frame.major, 1) 108 | self.assertEqual(frame.minor, 2) 109 | self.assertEqual(frame.tx_power, -8) 110 | self.assertIsNotNone(str(frame)) 111 | 112 | def test_cypress_beacon(self): 113 | """Test Cypress Cyalkit-E02 Sensor Beacon advertisement.""" 114 | cypress_packet = b"\x02\x01\x04\x1a\xff\x4c\x00\x02\x15\x00\x05\x00\x01\x00\x00\x10\x00"\ 115 | b"\x80\x00\x00\x80\x5f\x9b\x01\x31\x00\x02\x6c\x66\xc3" 116 | frame = parse_packet(cypress_packet) 117 | self.assertIsInstance(frame, IBeaconAdvertisement) 118 | self.assertEqual(frame.uuid, "00050001-0000-1000-8000-00805f9b0131") 119 | self.assertEqual(frame.major, 2) 120 | self.assertEqual(int(frame.cypress_temperature*100), 2316) 121 | self.assertEqual(int(frame.cypress_humidity*100), 4673) 122 | self.assertEqual(frame.tx_power, -61) 123 | self.assertIsNotNone(str(frame)) 124 | 125 | 126 | def test_estimote_telemetry_a(self): 127 | telemetry_a_packet = b"\x02\x01\x04\x03\x03\x9a\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5"\ 128 | b"\xeb\x03\x26\x40\x00\x00\x01\x41\x44\x47\xfa\xff\xff\xff\xff" 129 | frame = parse_packet(telemetry_a_packet) 130 | self.assertIsInstance(frame, EstimoteTelemetryFrameA) 131 | self.assertEqual(frame.identifier, "47a038d5eb032640") 132 | self.assertEqual(frame.protocol_version, 2) 133 | self.assertEqual(frame.acceleration, (0, 2/127.0, 130/127.0)) 134 | self.assertEqual(frame.is_moving, False) 135 | self.assertEqual(frame.current_motion_state, 420) 136 | self.assertEqual(frame.previous_motion_state, 240) 137 | self.assertEqual(frame.gpio_states, (1, 1, 1, 1)) 138 | self.assertEqual(frame.has_firmware_error, False) 139 | self.assertEqual(frame.has_clock_error, True) 140 | self.assertEqual(frame.pressure, None) 141 | self.assertIsNotNone(str(frame)) 142 | 143 | def test_estimote_telemetry_a2(self): 144 | telemetry_a_packet = b"\x02\x01\x04\x03\x03\x9a\xfe\x17\x16\x9a\xfe\x12\x47\xa0\x38\xd5"\ 145 | b"\xeb\x03\x26\x40\x00\x00\x01\x41\x44\x47\xf0\x01\x00\x00\x00" 146 | frame = parse_packet(telemetry_a_packet) 147 | self.assertIsInstance(frame, EstimoteTelemetryFrameA) 148 | self.assertEqual(frame.identifier, "47a038d5eb032640") 149 | self.assertEqual(frame.protocol_version, 1) 150 | self.assertEqual(frame.acceleration, (0, 2/127.0, 130/127.0)) 151 | self.assertEqual(frame.is_moving, False) 152 | self.assertEqual(frame.current_motion_state, 420) 153 | self.assertEqual(frame.previous_motion_state, 240) 154 | self.assertEqual(frame.gpio_states, (1, 1, 1, 1)) 155 | self.assertEqual(frame.has_firmware_error, True) 156 | self.assertEqual(frame.has_clock_error, False) 157 | self.assertEqual(frame.pressure, None) 158 | self.assertIsNotNone(str(frame)) 159 | 160 | def test_estimote_telemetry_a3(self): 161 | telemetry_a_packet = b"\x02\x01\x04\x03\x03\x9a\xfe\x17\x16\x9a\xfe\x02\x47\xa0\x38\xd5"\ 162 | b"\xeb\x03\x26\x40\x00\x00\x01\x41\x44\x47\xf0\x01\x00\x00\x00" 163 | frame = parse_packet(telemetry_a_packet) 164 | self.assertIsInstance(frame, EstimoteTelemetryFrameA) 165 | self.assertEqual(frame.identifier, "47a038d5eb032640") 166 | self.assertEqual(frame.protocol_version, 0) 167 | self.assertEqual(frame.acceleration, (0, 2/127.0, 130/127.0)) 168 | self.assertEqual(frame.is_moving, False) 169 | self.assertEqual(frame.current_motion_state, 420) 170 | self.assertEqual(frame.previous_motion_state, 240) 171 | self.assertEqual(frame.gpio_states, (1, 1, 1, 1)) 172 | self.assertEqual(frame.has_firmware_error, None) 173 | self.assertEqual(frame.has_clock_error, None) 174 | self.assertEqual(frame.pressure, None) 175 | self.assertIsNotNone(str(frame)) 176 | 177 | def test_estimote_telemetry_b(self): 178 | telemetry_b_packet = b"\x02\x01\x04\x03\x03\x9a\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5"\ 179 | b"\xeb\x03\x26\x40\x01\xff\xff\xff\xff\x49\x25\x66\xbc\x2e\x50" 180 | frame = parse_packet(telemetry_b_packet) 181 | self.assertIsInstance(frame, EstimoteTelemetryFrameB) 182 | self.assertEqual(frame.identifier, "47a038d5eb032640") 183 | self.assertEqual(frame.protocol_version, 2) 184 | self.assertEqual(frame.magnetic_field, None) 185 | self.assertEqual(frame.ambient_light, None) 186 | self.assertEqual(frame.uptime, 4870800) 187 | self.assertEqual(frame.temperature, 25.5) 188 | self.assertEqual(frame.has_firmware_error, None) 189 | self.assertEqual(frame.has_clock_error, None) 190 | self.assertEqual(frame.battery_level, 80) 191 | self.assertIsNotNone(str(frame)) 192 | 193 | def test_estimote_telemetry_b2(self): 194 | telemetry_b_packet = b"\x02\x01\x04\x03\x03\x9a\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5"\ 195 | b"\xeb\x03\x26\x40\x01\xd8\x42\xed\x73\x49\x25\x66\xbc\x2e\x50" 196 | frame = parse_packet(telemetry_b_packet) 197 | self.assertIsInstance(frame, EstimoteTelemetryFrameB) 198 | self.assertEqual(frame.identifier, "47a038d5eb032640") 199 | self.assertEqual(frame.protocol_version, 2) 200 | self.assertEqual(frame.magnetic_field, (-0.3125, 0.515625, -0.1484375)) 201 | self.assertEqual(frame.ambient_light, 276.48) 202 | self.assertEqual(frame.uptime, 4870800) 203 | self.assertEqual(frame.temperature, 25.5) 204 | self.assertEqual(frame.has_firmware_error, None) 205 | self.assertEqual(frame.has_clock_error, None) 206 | self.assertEqual(frame.battery_level, 80) 207 | self.assertIsNotNone(str(frame)) 208 | 209 | def test_estimote_telemetry_b3(self): 210 | telemetry_b_packet = b"\x02\x01\x04\x03\x03\x9a\xfe\x17\x16\x9a\xfe\x02\x47\xa0\x38\xd5"\ 211 | b"\xeb\x03\x26\x40\x01\xd8\x42\xed\x73\x49\x25\x66\xbc\x2e\x53" 212 | frame = parse_packet(telemetry_b_packet) 213 | self.assertIsInstance(frame, EstimoteTelemetryFrameB) 214 | self.assertEqual(frame.identifier, "47a038d5eb032640") 215 | self.assertEqual(frame.protocol_version, 0) 216 | self.assertEqual(frame.magnetic_field, (-0.3125, 0.515625, -0.1484375)) 217 | self.assertEqual(frame.ambient_light, 276.48) 218 | self.assertEqual(frame.uptime, 4870800) 219 | self.assertEqual(frame.temperature, 25.5) 220 | self.assertEqual(frame.has_firmware_error, True) 221 | self.assertEqual(frame.has_clock_error, True) 222 | self.assertEqual(frame.battery_level, None) 223 | self.assertIsNotNone(str(frame)) 224 | 225 | def test_estimote_nearable(self): 226 | nearable_packet = b"\x02\x01\x04\x03\x03\x0f" \ 227 | b"\x18\x17\xff\x5d\x01\x01\x1e\xfe\x42\x7e" \ 228 | b"\xb6\xf4\xbc\x2f\x04\x01\x68\xa1\xaa\xfe" \ 229 | b"\x05\xc1\x45\x25\x53" 230 | frame = parse_packet(nearable_packet) 231 | self.assertIsInstance(frame, EstimoteNearable) 232 | self.assertEqual("1efe427eb6f4bc2f", frame.identifier) 233 | self.assertEqual(22.5, frame.temperature) 234 | self.assertEqual(1, frame.firmware_version) 235 | self.assertEqual(4, frame.hardware_version) 236 | self.assertFalse(frame.is_moving) 237 | 238 | def test_exposure_notification(self): 239 | exposure_packet = b"\x02\x01\x1a\x03\x03\x6f\xfd\x17\x16\x6f\xfd\x0d\x3b\x4f" \ 240 | b"\x65\x58\x4c\x58\x21\x60\x57\x1d\xd1\x90\x10\xd4\x1c\x26" \ 241 | b"\x60\xee\x34\xd1" 242 | frame = parse_packet(exposure_packet) 243 | self.assertIsInstance(frame, ExposureNotificationFrame) 244 | self.assertEqual("0d3b4f65584c582160571dd19010d41c", frame.identifier) 245 | self.assertEqual(b"\x26\x60\xee\x34", frame.encrypted_metadata) 246 | 247 | 248 | if __name__ == "__main__": 249 | unittest.main() 250 | -------------------------------------------------------------------------------- /beacontools/scanner.py: -------------------------------------------------------------------------------- 1 | """Classes responsible for Beacon scanning.""" 2 | import logging 3 | import struct 4 | import threading 5 | from importlib import import_module 6 | from enum import IntEnum 7 | from construct import Struct, Byte, Bytes, GreedyRange, ConstructError 8 | 9 | from ahocorapy.keywordtree import KeywordTree 10 | 11 | from .const import (CJ_MANUFACTURER_ID, EDDYSTONE_UUID, 12 | ESTIMOTE_MANUFACTURER_ID, ESTIMOTE_UUID, 13 | EVT_LE_ADVERTISING_REPORT, EXPOSURE_NOTIFICATION_UUID, 14 | IBEACON_MANUFACTURER_ID, IBEACON_PROXIMITY_TYPE, 15 | LE_META_EVENT, MANUFACTURER_SPECIFIC_DATA_TYPE, 16 | MS_FRACTION_DIVIDER, OCF_LE_SET_SCAN_ENABLE, 17 | OCF_LE_SET_SCAN_PARAMETERS, OGF_LE_CTL, 18 | BluetoothAddressType, ScanFilter, ScannerMode, ScanType, 19 | OCF_LE_SET_EXT_SCAN_PARAMETERS, OCF_LE_SET_EXT_SCAN_ENABLE, 20 | EVT_LE_EXT_ADVERTISING_REPORT, OGF_INFO_PARAM, 21 | OCF_READ_LOCAL_VERSION, EVT_CMD_COMPLETE) 22 | from .device_filters import BtAddrFilter, DeviceFilter 23 | from .packet_types import (EddystoneEIDFrame, EddystoneEncryptedTLMFrame, 24 | EddystoneTLMFrame, EddystoneUIDFrame, 25 | EddystoneURLFrame) 26 | from .parser import parse_packet 27 | from .utils import (bin_to_int, bt_addr_to_string, get_mode, is_one_of, 28 | is_packet_type, to_int) 29 | 30 | 31 | class HCIVersion(IntEnum): 32 | """HCI version enumeration 33 | 34 | https://www.bluetooth.com/specifications/assigned-numbers/host-controller-interface/ 35 | """ 36 | BT_CORE_SPEC_1_0 = 0 37 | BT_CODE_SPEC_1_1 = 1 38 | BT_CODE_SPEC_1_2 = 2 39 | BT_CORE_SPEC_2_0 = 3 40 | BT_CORE_SPEC_2_1 = 4 41 | BT_CORE_SPEC_3_0 = 5 42 | BT_CORE_SPEC_4_0 = 6 43 | BT_CORE_SPEC_4_1 = 7 44 | BT_CORE_SPEC_4_2 = 8 45 | BT_CORE_SPEC_5_0 = 9 46 | BT_CORE_SPEC_5_1 = 10 47 | BT_CORE_SPEC_5_2 = 11 48 | 49 | 50 | _LOGGER = logging.getLogger(__name__) 51 | _LOGGER.setLevel(logging.DEBUG) 52 | 53 | # pylint: disable=no-member 54 | 55 | 56 | class BeaconScanner(object): 57 | """Scan for Beacon advertisements.""" 58 | 59 | def __init__(self, callback, bt_device_id=0, device_filter=None, packet_filter=None, scan_parameters=None): 60 | """Initialize scanner.""" 61 | # check if device filters are valid 62 | if device_filter is not None: 63 | if not isinstance(device_filter, list): 64 | device_filter = [device_filter] 65 | if len(device_filter) > 0: 66 | for filtr in device_filter: 67 | if not isinstance(filtr, DeviceFilter): 68 | raise ValueError("Device filters must be instances of DeviceFilter") 69 | else: 70 | device_filter = None 71 | 72 | # check if packet filters are valid 73 | if packet_filter is not None: 74 | if not isinstance(packet_filter, list): 75 | packet_filter = [packet_filter] 76 | if len(packet_filter) > 0: 77 | for filtr in packet_filter: 78 | if not is_packet_type(filtr): 79 | raise ValueError("Packet filters must be one of the packet types") 80 | else: 81 | packet_filter = None 82 | 83 | if scan_parameters is None: 84 | scan_parameters = {} 85 | 86 | self._mon = Monitor(callback, bt_device_id, device_filter, packet_filter, scan_parameters) 87 | 88 | def start(self): 89 | """Start beacon scanning.""" 90 | self._mon.start() 91 | 92 | def stop(self): 93 | """Stop beacon scanning.""" 94 | self._mon.terminate() 95 | 96 | 97 | class Monitor(threading.Thread): 98 | """Continously scan for BLE advertisements.""" 99 | 100 | def __init__(self, callback, bt_device_id, device_filter, packet_filter, scan_parameters): 101 | """Construct interface object.""" 102 | # do import here so that the package can be used in parsing-only mode (no bluez required) 103 | self.backend = import_module('beacontools.backend') 104 | 105 | threading.Thread.__init__(self) 106 | self.daemon = False 107 | self.keep_going = True 108 | self.callback = callback 109 | 110 | # number of the bt device (hciX) 111 | self.bt_device_id = bt_device_id 112 | # list of beacons to monitor 113 | self.device_filter = device_filter 114 | self.mode = get_mode(device_filter) 115 | # list of packet types to monitor 116 | self.packet_filter = packet_filter 117 | # bluetooth socket 118 | self.socket = None 119 | # keep track of Eddystone Beacon <-> bt addr mapping 120 | self.eddystone_mappings = [] 121 | # parameters to pass to bt device 122 | self.scan_parameters = scan_parameters 123 | # hci version 124 | self.hci_version = HCIVersion.BT_CORE_SPEC_1_0 125 | 126 | # construct an aho-corasick search tree for efficient prefiltering 127 | service_uuid_prefix = b"\x03\x03" 128 | self.kwtree = KeywordTree() 129 | if self.mode & ScannerMode.MODE_IBEACON: 130 | self.kwtree.add(bytes([MANUFACTURER_SPECIFIC_DATA_TYPE]) + IBEACON_MANUFACTURER_ID + IBEACON_PROXIMITY_TYPE) 131 | if self.mode & ScannerMode.MODE_EDDYSTONE: 132 | self.kwtree.add(service_uuid_prefix + EDDYSTONE_UUID) 133 | if self.mode & ScannerMode.MODE_ESTIMOTE: 134 | self.kwtree.add(service_uuid_prefix + ESTIMOTE_UUID) 135 | self.kwtree.add(bytes([MANUFACTURER_SPECIFIC_DATA_TYPE]) + ESTIMOTE_MANUFACTURER_ID) 136 | if self.mode & ScannerMode.MODE_CJMONITOR: 137 | self.kwtree.add(bytes([MANUFACTURER_SPECIFIC_DATA_TYPE]) + CJ_MANUFACTURER_ID) 138 | if self.mode & ScannerMode.MODE_EXPOSURE_NOTIFICATION: 139 | self.kwtree.add(service_uuid_prefix + EXPOSURE_NOTIFICATION_UUID) 140 | self.kwtree.finalize() 141 | 142 | def run(self): 143 | """Continously scan for BLE advertisements.""" 144 | self.socket = self.backend.open_dev(self.bt_device_id) 145 | 146 | self.hci_version = self.get_hci_version() 147 | self.set_scan_parameters(**self.scan_parameters) 148 | self.toggle_scan(True) 149 | 150 | while self.keep_going: 151 | pkt = self.socket.recv(255) 152 | event = to_int(pkt[1]) 153 | subevent = to_int(pkt[3]) 154 | if event == LE_META_EVENT and subevent in [EVT_LE_ADVERTISING_REPORT, EVT_LE_EXT_ADVERTISING_REPORT]: 155 | # we have an BLE advertisement 156 | self.process_packet(pkt) 157 | self.socket.close() 158 | 159 | def get_hci_version(self): 160 | """Gets the HCI version""" 161 | local_version = Struct( 162 | "status" / Byte, 163 | "hci_version" / Byte, 164 | "hci_revision" / Bytes(2), 165 | "lmp_version" / Byte, 166 | "manufacturer_name" / Bytes(2), 167 | "lmp_subversion" / Bytes(2), 168 | ) 169 | 170 | try: 171 | resp = self.backend.send_req(self.socket, OGF_INFO_PARAM, OCF_READ_LOCAL_VERSION, 172 | EVT_CMD_COMPLETE, local_version.sizeof(), bytes(), 0) 173 | return HCIVersion(GreedyRange(local_version).parse(resp)[0]["hci_version"]) 174 | except (ConstructError, NotImplementedError): 175 | return HCIVersion.BT_CORE_SPEC_1_0 176 | 177 | def set_scan_parameters(self, scan_type=ScanType.ACTIVE, interval_ms=10, window_ms=10, 178 | address_type=BluetoothAddressType.RANDOM, filter_type=ScanFilter.ALL): 179 | """"Sets the le scan parameters 180 | 181 | For extended set scan parameters command additional parameter scanning PHYs has to be provided. 182 | The parameter indicates the PHY(s) on which the advertising packets should be received on the 183 | primary advertising physical channel. For further information have a look on BT Core 5.1 Specification, 184 | page 1439 ( LE Set Extended Scan Parameters command). 185 | 186 | Args: 187 | scan_type: ScanType.(PASSIVE|ACTIVE) 188 | interval: ms (as float) between scans (valid range 2.5ms - 10240ms or 40.95s for extended version) 189 | ..note:: when interval and window are equal, the scan 190 | runs continuos 191 | window: ms (as float) scan duration (valid range 2.5ms - 10240ms or 40.95s for extended version) 192 | address_type: Bluetooth address type BluetoothAddressType.(PUBLIC|RANDOM) 193 | * PUBLIC = use device MAC address 194 | * RANDOM = generate a random MAC address and use that 195 | filter: ScanFilter.(ALL|WHITELIST_ONLY) only ALL is supported, which will 196 | return all fetched bluetooth packets (WHITELIST_ONLY is not supported, 197 | because OCF_LE_ADD_DEVICE_TO_WHITE_LIST command is not implemented) 198 | 199 | Raises: 200 | ValueError: A value had an unexpected format or was not in range 201 | """ 202 | max_interval = (0x4000 if self.hci_version < HCIVersion.BT_CORE_SPEC_5_0 else 0xFFFF) 203 | interval_fractions = interval_ms / MS_FRACTION_DIVIDER 204 | if interval_fractions < 0x0004 or interval_fractions > max_interval: 205 | raise ValueError( 206 | "Invalid interval given {}, must be in range of 2.5ms to {}ms!".format( 207 | interval_fractions, max_interval * MS_FRACTION_DIVIDER)) 208 | window_fractions = window_ms / MS_FRACTION_DIVIDER 209 | if window_fractions < 0x0004 or window_fractions > max_interval: 210 | raise ValueError( 211 | "Invalid window given {}, must be in range of 2.5ms to {}ms!".format( 212 | window_fractions, max_interval * MS_FRACTION_DIVIDER)) 213 | 214 | interval_fractions, window_fractions = int(interval_fractions), int(window_fractions) 215 | 216 | if self.hci_version < HCIVersion.BT_CORE_SPEC_5_0: 217 | command_field = OCF_LE_SET_SCAN_PARAMETERS 218 | scan_parameter_pkg = struct.pack( 219 | "', 139 | str(args[2])) 140 | self.assertEqual( 141 | {"beacon_type": CJ_TEMPHUM_TYPE, 142 | "company_id": CJ_MANUFACTURER_ID, 143 | 'name': 'Mon 5643', 144 | 'light': 159.9, 145 | "temperature": 32.6, 146 | "humidity": 55}, 147 | args[3]) 148 | # Test same packet with different beacon type, should be ignored. 149 | scanner = BeaconScanner(callback, device_filter=CJMonitorFilter(beacon_type=4351)) 150 | scanner._mon.process_packet(pkt) 151 | self.assertEqual(1, callback.call_count) 152 | 153 | def test_process_packet_dev_packet(self): 154 | """Test processing of a packet and callback execution with device and packet filter.""" 155 | callback = MagicMock() 156 | scanner = BeaconScanner( 157 | callback, 158 | device_filter=EddystoneFilter(namespace="12345678901234678901"), 159 | packet_filter=EddystoneUIDFrame 160 | ) 161 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 162 | b"\xfe\x11\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12\x34\x67\x89\x01\x00\x00\x00"\ 163 | b"\x00\x00\x01\x00\x00\xdd" 164 | scanner._mon.process_packet(pkt) 165 | self.assertEqual(callback.call_count, 1) 166 | args = callback.call_args[0] 167 | self.assertEqual(args[0], "1c:d6:cd:ef:94:35") 168 | self.assertEqual(args[1], -35) 169 | self.assertIsInstance(args[2], EddystoneUIDFrame) 170 | self.assertEqual(args[3], { 171 | "namespace":"12345678901234678901", 172 | "instance":"000000000001" 173 | }) 174 | 175 | def test_process_packet_filter(self): 176 | """Test processing of a packet and callback execution with packet filter.""" 177 | callback = MagicMock() 178 | scanner = BeaconScanner( 179 | callback, 180 | packet_filter=EddystoneUIDFrame 181 | ) 182 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 183 | b"\xfe\x11\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12\x34\x67\x89\x01\x00\x00\x00"\ 184 | b"\x00\x00\x01\x00\x00\xdd" 185 | scanner._mon.process_packet(pkt) 186 | self.assertEqual(callback.call_count, 1) 187 | args = callback.call_args[0] 188 | self.assertEqual(args[0], "1c:d6:cd:ef:94:35") 189 | self.assertEqual(args[1], -35) 190 | self.assertIsInstance(args[2], EddystoneUIDFrame) 191 | self.assertEqual(args[3], { 192 | "namespace":"12345678901234678901", 193 | "instance":"000000000001" 194 | }) 195 | 196 | def test_process_packet_filter_bad(self): 197 | """Test processing of a packet and callback execution with packet filter.""" 198 | callback = MagicMock() 199 | scanner = BeaconScanner( 200 | callback, 201 | packet_filter=EddystoneTLMFrame 202 | ) 203 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 204 | b"\xfe\x11\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12\x34\x67\x89\x01\x00\x00\x00"\ 205 | b"\x00\x00\x01\x00\x00\xdd" 206 | scanner._mon.process_packet(pkt) 207 | callback.assert_not_called() 208 | 209 | def test_repr_filter(self): 210 | self.assertEqual(BtAddrFilter("aa:bb:cc:dd:ee:ff").__repr__(), "BtAddrFilter(bt_addr=aa:bb:cc:dd:ee:ff)") 211 | 212 | def test_wrong_btaddr(self): 213 | self.assertRaises(ValueError, BtAddrFilter, "az") 214 | self.assertRaises(ValueError, BtAddrFilter, None) 215 | self.assertRaises(ValueError, BtAddrFilter, "aa-bb-cc-dd-ee-fg") 216 | self.assertRaises(ValueError, BtAddrFilter, "aa-bb-cc-dd-ee-ff") 217 | self.assertRaises(ValueError, BtAddrFilter, "aabb.ccdd.eeff") 218 | self.assertRaises(ValueError, BtAddrFilter, "aa:bb:cc:dd:ee:") 219 | 220 | def test_process_packet_btaddr(self): 221 | """Test processing of a packet and callback execution with bt addr filter.""" 222 | callback = MagicMock() 223 | scanner = BeaconScanner( 224 | callback, 225 | device_filter=BtAddrFilter("1c:d6:cd:ef:94:35") 226 | ) 227 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 228 | b"\xfe\x11\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12\x34\x67\x89\x01\x00\x00\x00"\ 229 | b"\x00\x00\x01\x00\x00\xdd" 230 | scanner._mon.process_packet(pkt) 231 | self.assertEqual(callback.call_count, 1) 232 | args = callback.call_args[0] 233 | self.assertEqual(args[0], "1c:d6:cd:ef:94:35") 234 | self.assertEqual(args[1], -35) 235 | self.assertIsInstance(args[2], EddystoneUIDFrame) 236 | self.assertEqual(args[3], { 237 | "namespace":"12345678901234678901", 238 | "instance":"000000000001" 239 | }) 240 | 241 | def test_process_packet_bad_packet(self): 242 | """Test processing of a packet and callback execution with a bad packet.""" 243 | callback = MagicMock() 244 | scanner = BeaconScanner( 245 | callback, 246 | device_filter=EddystoneFilter(namespace="12345678901234678901"), 247 | packet_filter=EddystoneUIDFrame 248 | ) 249 | pkt = b"\x41\x3e\x41\x02\x01\x03" 250 | scanner._mon.process_packet(pkt) 251 | callback.assert_not_called() 252 | 253 | def test_process_packet_bad_packet2(self): 254 | """Test processing of a packet and callback execution with a bad packet.""" 255 | callback = MagicMock() 256 | scanner = BeaconScanner( 257 | callback, 258 | device_filter=EddystoneFilter(namespace="12345678901234678901"), 259 | packet_filter=EddystoneUIDFrame 260 | ) 261 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 262 | b"\xfe" 263 | scanner._mon.process_packet(pkt) 264 | callback.assert_not_called() 265 | 266 | def test_process_packet_estimote_a(self): 267 | """Test processing of a estimote telemetry a packet and callback execution with packet filter.""" 268 | callback = MagicMock() 269 | scanner = BeaconScanner(callback, packet_filter=[EstimoteTelemetryFrameB, EstimoteTelemetryFrameA]) 270 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x04\x03\x03\x9a"\ 271 | b"\xfe\x17\x16\x9a\xfe\x12\x47\xa0\x38\xd5\xeb\x03\x26\x40\x00\x00\x01\x41\x44\x47"\ 272 | b"\xf0\x01\x00\x00\x00\xdd" 273 | scanner._mon.process_packet(pkt) 274 | self.assertEqual(callback.call_count, 1) 275 | args = callback.call_args[0] 276 | self.assertEqual(args[0], "1c:d6:cd:ef:94:35") 277 | self.assertEqual(args[1], -35) 278 | self.assertIsInstance(args[2], EstimoteTelemetryFrameA) 279 | self.assertEqual(args[3], { 280 | "identifier": "47a038d5eb032640", 281 | "protocol_version": 1 282 | }) 283 | 284 | def test_process_packet_estimote_b(self): 285 | """Test processing of a estimote telemetry b packet and callback execution with packet filter.""" 286 | callback = MagicMock() 287 | scanner = BeaconScanner(callback, packet_filter=[EstimoteTelemetryFrameB, EstimoteTelemetryFrameA]) 288 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x04\x03\x03\x9a"\ 289 | b"\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5\xeb\x03\x26\x40\x01\xff\xff\xff\xff\x49"\ 290 | b"\x25\x66\xbc\x2e\x50\xdd" 291 | scanner._mon.process_packet(pkt) 292 | self.assertEqual(callback.call_count, 1) 293 | args = callback.call_args[0] 294 | self.assertEqual(args[0], "1c:d6:cd:ef:94:35") 295 | self.assertEqual(args[1], -35) 296 | self.assertIsInstance(args[2], EstimoteTelemetryFrameB) 297 | self.assertEqual(args[3], { 298 | "identifier": "47a038d5eb032640", 299 | "protocol_version": 2 300 | }) 301 | 302 | def test_process_packet_estimote_device_filter(self): 303 | """Test processing of a estimote packet and callback execution with device filter.""" 304 | callback = MagicMock() 305 | scanner = BeaconScanner(callback, device_filter=EstimoteFilter(protocol_version=2)) 306 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x04\x03\x03\x9a"\ 307 | b"\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5\xeb\x03\x26\x40\x01\xff\xff\xff\xff\x49"\ 308 | b"\x25\x66\xbc\x2e\x50\xdd" 309 | scanner._mon.process_packet(pkt) 310 | self.assertEqual(callback.call_count, 1) 311 | args = callback.call_args[0] 312 | self.assertEqual(args[0], "1c:d6:cd:ef:94:35") 313 | self.assertEqual(args[1], -35) 314 | self.assertIsInstance(args[2], EstimoteTelemetryFrameB) 315 | self.assertEqual(args[3], { 316 | "identifier": "47a038d5eb032640", 317 | "protocol_version": 2 318 | }) 319 | 320 | def test_process_packet_exposure_notification_filter(self): 321 | """Test processing of a exposure notification with device filter.""" 322 | callback = MagicMock() 323 | scanner = BeaconScanner( 324 | callback, 325 | device_filter=ExposureNotificationFilter(identifier="0d3b4f65584c582160571dd19010d41c") 326 | ) 327 | # correct identifier 328 | pkt = b"\x04\x3e\x2b\x02\x01\x03\x01\xe1\xac\xca\x3d\xea\x0d\x1f\x02\x01\x1a\x03\x03\x6f" \ 329 | b"\xfd\x17\x16\x6f\xfd\x0d\x3b\x4f\x65\x58\x4c\x58\x21\x60\x57\x1d\xd1\x90\x10\xd4" \ 330 | b"\x1c\x26\x60\xee\x34\xd1" 331 | # different identifier 332 | pkt2 = b"\x04\x3e\x2b\x02\x01\x03\x01\xe1\xac\xca\x3d\xea\x0d\x1f\x02\x01\x1a\x03\x03\x6f" \ 333 | b"\xfd\x17\x16\x6f\xfd\x0d\x3b\x40\x65\x58\x4c\x58\x21\x60\x57\x1d\xd1\x90\x10\xd4" \ 334 | b"\x1c\x26\x60\xee\x34\xd1" 335 | scanner._mon.process_packet(pkt) 336 | scanner._mon.process_packet(pkt2) 337 | self.assertEqual(callback.call_count, 1) 338 | args = callback.call_args[0] 339 | self.assertEqual(args[0], "0d:ea:3d:ca:ac:e1") 340 | self.assertEqual(args[1], -47) 341 | self.assertIsInstance(args[2], ExposureNotificationFrame) 342 | self.assertEqual(args[3], { 343 | "identifier": "0d3b4f65584c582160571dd19010d41c", 344 | "encrypted_metadata": b"\x26\x60\xee\x34" 345 | }) 346 | 347 | def test_invalid_bt_filter(self): 348 | """Test passing of an invalid bluetooth address as filter.""" 349 | callback = MagicMock() 350 | with self.assertRaises(ValueError): 351 | BeaconScanner(callback, device_filter=BtAddrFilter("this is crap")) 352 | 353 | def test_multiple_filters(self): 354 | callback = MagicMock() 355 | scanner = BeaconScanner(callback, device_filter=EstimoteFilter(protocol_version=2), packet_filter=EstimoteTelemetryFrameB) 356 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x04\x03\x03\x9a"\ 357 | b"\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5\xeb\x03\x26\x40\x01\xff\xff\xff\xff\x49"\ 358 | b"\x25\x66\xbc\x2e\x50\xdd" 359 | scanner._mon.process_packet(pkt) 360 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x04\x03\x03\x9a"\ 361 | b"\xfe\x17\x16\x9a\xfe\x12\x47\xa0\x38\xd5\xeb\x03\x26\x40\x00\x00\x01\x41\x44\x47"\ 362 | b"\xf0\x01\x00\x00\x00\xdd" 363 | scanner._mon.process_packet(pkt) 364 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 365 | b"\xfe\x11\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12\x34\x67\x89\x01\x00\x00\x00"\ 366 | b"\x00\x00\x01\x00\x00\xdd" 367 | scanner._mon.process_packet(pkt) 368 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 369 | b"\xfe\x11\x16\xaa\xfe\x20\x00\x0b\x18\x13\x00\x00\x00\x14\x67\x00\x00\x2a\xc4\xe4" 370 | scanner._mon.process_packet(pkt) 371 | self.assertEqual(callback.call_count, 1) 372 | 373 | def test_multiple_filters2(self): 374 | callback = MagicMock() 375 | scanner = BeaconScanner(callback, device_filter=[EstimoteFilter(identifier="47a038d5eb032640", protocol_version=2), EddystoneFilter(instance="000000000001")], 376 | packet_filter=[EstimoteTelemetryFrameB, EddystoneUIDFrame]) 377 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x04\x03\x03\x9a"\ 378 | b"\xfe\x17\x16\x9a\xfe\x22\x47\xa0\x38\xd5\xeb\x03\x26\x40\x01\xff\xff\xff\xff\x49"\ 379 | b"\x25\x66\xbc\x2e\x50\xdd" 380 | scanner._mon.process_packet(pkt) 381 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x04\x03\x03\x9a"\ 382 | b"\xfe\x17\x16\x9a\xfe\x12\x47\xa0\x38\xd5\xeb\x03\x26\x40\x00\x00\x01\x41\x44\x47"\ 383 | b"\xf0\x01\x00\x00\x00\xdd" 384 | scanner._mon.process_packet(pkt) 385 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 386 | b"\xfe\x11\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12\x34\x67\x89\x01\x00\x00\x00"\ 387 | b"\x00\x00\x01\x00\x00\xdd" 388 | scanner._mon.process_packet(pkt) 389 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 390 | b"\xfe\x11\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12\x34\x67\x89\x01\x00\x00\x00"\ 391 | b"\x00\x00\x02\x00\x00\xdd" 392 | scanner._mon.process_packet(pkt) 393 | pkt = b"\x41\x3e\x41\x02\x01\x03\x01\x35\x94\xef\xcd\xd6\x1c\x19\x02\x01\x06\x03\x03\xaa"\ 394 | b"\xfe\x11\x16\xaa\xfe\x00\xe3\x12\x34\x56\x78\x90\x12\x34\x67\x89\x01\x00\x00\x00"\ 395 | b"\x00\x00\x01\x00\x00\xdd" 396 | scanner._mon.process_packet(pkt) 397 | self.assertEqual(callback.call_count, 3) 398 | 399 | def test_exposure_notification(self): 400 | callback = MagicMock() 401 | scanner = BeaconScanner(callback, packet_filter=[ExposureNotificationFrame]) 402 | # Android and iOS seem to use slightly different packets 403 | android_pkt = b"\x04\x3E\x28\x02\x01\x03\x01\xBB\x7E\xB5\x2B\x86\x79\x1C\x03\x03\x6F\xFD\x17\x16"\ 404 | b"\x6F\xFD\x2C\xFB\x0D\xE0\x2B\x33\xD2\x0C\x5C\x27\x61\x12\x38\xE2\xD1\x07\x42\xB5"\ 405 | b"\x6E\xE5\xB8" 406 | scanner._mon.process_packet(android_pkt) 407 | ios_pkt = b"\x04\x3E\x2B\x02\x01\x03\x01\x08\xE6\xAE\x33\x0B\x3F\x1F\x02\x01\x1A\x03\x03\x6F"\ 408 | b"\xFD\x17\x16\x6F\xFD\xE9\x32\xE8\xB0\x68\x8D\xFA\xEC\x00\x62\xB7\xD6\xD3\x5E\xEF"\ 409 | b"\xB5\xEE\xAA\x91\xAC\xBA" 410 | scanner._mon.process_packet(ios_pkt) 411 | self.assertEqual(callback.call_count, 2) 412 | 413 | if __name__ == "__main__": 414 | unittest.main() 415 | --------------------------------------------------------------------------------