Skip to content

Commit

Permalink
[WIP] release 1.3.0 (#26)
Browse files Browse the repository at this point in the history
* relaxed eddystone parsing rules (rfu and flags)
* added estimote telemetry packet types
* added 8.8 fixed point parsing and more test cases
  • Loading branch information
citruz committed Aug 26, 2018
1 parent 246144c commit bf14608
Show file tree
Hide file tree
Showing 20 changed files with 701 additions and 98 deletions.
5 changes: 5 additions & 0 deletions README.rst
Expand Up @@ -8,6 +8,7 @@ Currently supported types are:

* `Eddystone Beacons <https://github.com/google/eddystone/>`__
* `iBeacons <https://developer.apple.com/ibeacon/>`__ (Apple and Cypress CYALKIT-E02)
* `Estimote Beacons (Telemetry only) <https://github.com/estimote/estimote-specs>`__

The BeaconTools library has two main components:

Expand Down Expand Up @@ -94,6 +95,10 @@ Changelog
---------
Beacontools follows the `semantic versioning <https://semver.org/>`__ scheme.

* 1.3.0
* Added support for Estimote Telemetry packets (see examples/parser_example.py)
* Relaxed parsing constraints for RFU and flags field
* Added temperature output in 8.8 fixed point decimal format for Eddystone TLM
* 1.2.4
* Added support for Eddystone packets with Flags Data set to 0x1a (thanks to `AndreasTornes <https://github.com/AndreasTornes>`__)
* Updated depedencies
Expand Down
3 changes: 2 additions & 1 deletion beacontools/__init__.py
Expand Up @@ -6,4 +6,5 @@
EddystoneEncryptedTLMFrame, EddystoneTLMFrame, \
EddystoneEIDFrame
from .packet_types.ibeacon import IBeaconAdvertisement
from .device_filters import IBeaconFilter, EddystoneFilter, BtAddrFilter
from .packet_types.estimote import EstimoteTelemetryFrameA, EstimoteTelemetryFrameB
from .device_filters import IBeaconFilter, EddystoneFilter, BtAddrFilter, EstimoteFilter
18 changes: 15 additions & 3 deletions beacontools/const.py
@@ -1,9 +1,14 @@
"""Constants."""
from enum import IntEnum

# for scanner
MODE_IBEACON = 1
MODE_EDDYSTONE = 2
MODE_BOTH = 3
class ScannerMode(IntEnum):
"""Used to determine which packets should be parsed by the scanner."""
MODE_NONE = 0
MODE_IBEACON = 1
MODE_EDDYSTONE = 2
MODE_ESTIMOTE = 4
MODE_ALL = MODE_IBEACON | MODE_EDDYSTONE | MODE_ESTIMOTE

LE_META_EVENT = 0x3e
OGF_LE_CTL = 0x08
Expand Down Expand Up @@ -53,3 +58,10 @@
IBEACON_COMPANY_ID = b"\x4c\x00"
IBEACON_PROXIMITY_TYPE = b"\x02\x15"
CYPRESS_BEACON_DEFAULT_UUID = "00050001-0000-1000-8000-00805f9b0131"

# for Estimote
ESTIMOTE_UUID = b"\x9a\xfe"

ESTIMOTE_TELEMETRY_FRAME = 0x2
ESTIMOTE_TELEMETRY_SUBFRAME_A = 0
ESTIMOTE_TELEMETRY_SUBFRAME_B = 1
22 changes: 19 additions & 3 deletions beacontools/device_filters.py
Expand Up @@ -27,7 +27,8 @@ class IBeaconFilter(DeviceFilter):
def __init__(self, uuid=None, major=None, minor=None):
"""Initialize filter."""
super(IBeaconFilter, self).__init__()
# TODO validate arguments
if not uuid and not major and not minor:
raise ValueError("IBeaconFilter needs at least one argument set")
if uuid:
self.properties['uuid'] = uuid
if major:
Expand All @@ -41,17 +42,32 @@ class EddystoneFilter(DeviceFilter):
def __init__(self, namespace=None, instance=None):
"""Initialize filter."""
super(EddystoneFilter, self).__init__()
# TODO validate arguments
if not namespace and not instance:
raise ValueError("EddystoneFilter needs at least one argument set")
if namespace:
self.properties['namespace'] = namespace
if instance:
self.properties['instance'] = instance

class EstimoteFilter(DeviceFilter):
"""Filter for Estimote beacons."""

def __init__(self, identifier=None, protocol_version=None):
"""Initialize filter."""
super(EstimoteFilter, self).__init__()
if not identifier and not protocol_version:
raise ValueError("EstimoteFilter needs at least one argument set")
if identifier:
self.properties['identifier'] = identifier
if protocol_version:
self.properties['protocol_version'] = protocol_version

class BtAddrFilter(DeviceFilter):
"""Filter by bluetooth address."""

def __init__(self, bt_addr):
"""Initialize filter."""
super(BtAddrFilter, self).__init__()
# TODO validate arguments
if not bt_addr or len(bt_addr) != 17:
raise ValueError("Invalid bluetooth given, need to be in format aa:bb:cc:dd:ee:ff")
self.properties['bt_addr'] = bt_addr
1 change: 1 addition & 0 deletions beacontools/packet_types/__init__.py
Expand Up @@ -2,3 +2,4 @@
from .eddystone import EddystoneUIDFrame, EddystoneURLFrame, EddystoneEncryptedTLMFrame, \
EddystoneTLMFrame, EddystoneEIDFrame
from .ibeacon import IBeaconAdvertisement
from .estimote import EstimoteTelemetryFrameA, EstimoteTelemetryFrameB
6 changes: 6 additions & 0 deletions beacontools/packet_types/eddystone.py
Expand Up @@ -99,6 +99,7 @@ class EddystoneTLMFrame(object):
def __init__(self, data):
self._voltage = data['voltage']
self._temperature = data['temperature']
self._temperature_fixed_point = data['temperature'] / float(256)
self._advertising_count = data['advertising_count']
self._seconds_since_boot = data['seconds_since_boot']

Expand All @@ -112,6 +113,11 @@ def temperature(self):
"""Temperature in degree Celsius."""
return self._temperature

@property
def temperature_fixed_point(self):
"""Temperature interpreted as 8.8 fixed point decimal."""
return self._temperature_fixed_point

@property
def advertising_count(self):
"""Advertising PDU count."""
Expand Down
235 changes: 235 additions & 0 deletions beacontools/packet_types/estimote.py
@@ -0,0 +1,235 @@
"""Packet classes for Estimote beacons."""
from ..utils import data_to_hexstring

class EstimoteTelemetryFrameA(object):
"""Estimote telemetry subframe A."""

def __init__(self, data, protocol_version):
self._protocol_version = protocol_version
self._identifier = data_to_hexstring(data['identifier'])
sub = data['sub_frame']
# acceleration: convert to tuple and normalize
self._acceleration = tuple([v * 2 / 127.0 for v in sub['acceleration']])
# motion states
self._previous_motion_state = self.parse_motion_state(sub['previous_motion'])
self._current_motion_state = self.parse_motion_state(sub['current_motion'])
self._is_moving = (sub['combined_fields'][0] & 0b00000011) == 1
# gpio
states = []
for i in range(4):
states.append((sub['combined_fields'][0] & (1 << (4+i))) != 0)
self._gpio_states = tuple(states)
# error codes
if self.protocol_version == 2:
self._has_firmware_error = ((sub['combined_fields'][0] & 0b00000100) >> 2) == 1
self._has_clock_error = ((sub['combined_fields'][0] & 0b00001000) >> 3) == 1
elif self.protocol_version == 1:
self._has_firmware_error = (sub['combined_fields'][1] & 0b00000001) == 1
self._has_clock_error = ((sub['combined_fields'][1] & 0b00000010) >> 1) == 1
else:
self._has_firmware_error = None
self._has_clock_error = None
# pressure
if self.protocol_version == 2:
self._pressure = sub['combined_fields'][1] | \
sub['combined_fields'][2] << 8 | \
sub['combined_fields'][3] << 16 | \
sub['combined_fields'][4] << 24
if self._pressure == 0xffffffff:
self._pressure = None
else:
self._pressure /= 256.0
else:
self._pressure = None

@staticmethod
def parse_motion_state(val):
"""Convert motion state byte to seconds."""
number = val & 0b00111111
unit = (val & 0b11000000) >> 6
if unit == 1:
number *= 60 # minutes
elif unit == 2:
number *= 60 * 60 # hours
elif unit == 3 and number < 32:
number *= 60 * 60 * 24 # days
elif unit == 3:
number -= 32
number *= 60 * 60 * 24 * 7 # weeks
return number

@property
def protocol_version(self):
"""Protocol version of the packet."""
return self._protocol_version

@property
def identifier(self):
"""First half of the identifier of the beacon (8 bytes)."""
return self._identifier

@property
def acceleration(self):
"""Tuple of acceleration values for (X, Y, Z) axis, in g."""
return self._acceleration

@property
def is_moving(self):
"""Whether the beacon is in motion at the moment (Bool)"""
return self._is_moving

@property
def current_motion_state(self):
"""Duration of current motion state in seconds.
E.g., if is_moving is True, this states how long the beacon is beeing moved already and
previous_motion_state will tell how long it has been still before."""
return self._current_motion_state


@property
def previous_motion_state(self):
"""Duration of previous motion state in seconds (see current_motion_state)."""
return self._previous_motion_state

@property
def gpio_states(self):
"""Tuple with state of the GPIO pins 0-3 (True is high, False is low)."""
return self._gpio_states

@property
def has_firmware_error(self):
"""If beacon has a firmware problem.
Only available if protocol version > 0, None otherwise."""
return self._has_firmware_error

@property
def has_clock_error(self):
"""If beacon has a clock problem. Only available if protocol version > 0, None otherwise."""
return self._has_clock_error

@property
def pressure(self):
"""Atmosperic pressure in Pascal. None if all bits are set.
Only available if protocol version is 2, None otherwise ."""
return self._pressure

@property
def properties(self):
"""Get beacon properties."""
return {'identifier': self.identifier, 'protocol_version': self.protocol_version}

def __str__(self):
return "EstimoteTelemetryFrameA<identifier: %s, protocol_version: %u>" \
% (self.identifier, self.protocol_version)


class EstimoteTelemetryFrameB(object):
"""Estimote telemetry subframe B."""

def __init__(self, data, protocol_version):
self._protocol_version = protocol_version
self._identifier = data_to_hexstring(data['identifier'])
sub = data['sub_frame']
# magnetic field: convert to tuple and normalize
if sub['magnetic_field'] == [-1, -1, -1]:
self._magnetic_field = None
else:
self._magnetic_field = tuple([v / 128.0 for v in sub['magnetic_field']])
# ambient light
ambient_upper = (sub['ambient_light'] & 0b11110000) >> 4
ambient_lower = sub['ambient_light'] & 0b00001111
if ambient_upper == 0xf and ambient_lower == 0xf:
self._ambient_light = None
else:
self._ambient_light = pow(2, ambient_upper) * ambient_lower * 0.72
# uptime
uptime_unit_code = (sub['combined_fields'][1] & 0b00110000) >> 4
uptime_number = ((sub['combined_fields'][1] & 0b00001111) << 8) | \
sub['combined_fields'][0]
if uptime_unit_code == 1:
uptime_number *= 60 # minutes
elif uptime_unit_code == 2:
uptime_number *= 60 * 60 # hours
elif uptime_unit_code == 3:
uptime_number *= 60 * 60 * 24 # days
else:
uptime_number = 0
self._uptime = uptime_number
# temperature
temperature = ((sub['combined_fields'][3] & 0b00000011) << 10) | \
(sub['combined_fields'][2] << 2) | \
((sub['combined_fields'][1] & 0b11000000) >> 6)
temperature = temperature - 4096 if temperature > 2047 else temperature
self._temperature = temperature / 16.0
# battery voltage
voltage = (sub['combined_fields'][4] << 6) | \
((sub['combined_fields'][3] & 0b11111100) >> 2)
self._voltage = None if voltage == 0b11111111111111 else voltage
if self._protocol_version == 0:
# errors (only protocol ver 0)
self._has_firmware_error = (sub['battery_level'] & 0b00000001) == 1
self._has_clock_error = (sub['battery_level'] & 0b00000010) == 0b10
self._battery_level = None
else:
self._battery_level = None if sub['battery_level'] == 0xFF else sub['battery_level']
self._has_clock_error = None
self._has_firmware_error = None


@property
def protocol_version(self):
"""Protocol version of the packet."""
return self._protocol_version

@property
def identifier(self):
"""First half of the identifier of the beacon (8 bytes)."""
return self._identifier

@property
def magnetic_field(self):
"""Tuple of magnetic field values for (X, Y, Z) axis.
Between -1 and 1 or None if all bits are set."""
return self._magnetic_field

@property
def ambient_light(self):
"""Ambient light in lux."""
return self._ambient_light

@property
def uptime(self):
"""Uptime in seconds."""
return self._uptime

@property
def temperature(self):
"""Ambient temperature in celsius."""
return self._temperature

@property
def has_firmware_error(self):
"""Whether beacon has a firmware problem.
Only available if protocol version is 0, None otherwise."""
return self._has_firmware_error

@property
def has_clock_error(self):
"""Whether beacon has a clock problem.
Only available if protocol version is 0, None otherwise."""
return self._has_clock_error

@property
def battery_level(self):
"""Beacon battery level between 0 and 100.
None if protocol version is 0 or not measured yet."""
return self._battery_level

@property
def properties(self):
"""Get beacon properties."""
return {'identifier': self.identifier, 'protocol_version': self.protocol_version}

def __str__(self):
return "EstimoteTelemetryFrameB<identifier: %s, protocol_version: %u>" \
% (self.identifier, self.protocol_version)

0 comments on commit bf14608

Please sign in to comment.