diff --git a/README.md b/README.md index 6bc05b1..16b3c5f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Build Status](https://travis-ci.org/ruuvi-friends/simple-ruuvitag.svg?branch=master)](https://travis-ci.org/ruuvi-friends/simple-ruuvitag) + # Simple Ruuvitag 🔧 Simple Ruuvitag is a hard frok and ***HEAVY*** simplification of [ttu work](https://github.com/ttu) [ruuvitag-sensor](https://github.com/ttu/ruuvitag-sensor) @@ -5,6 +7,9 @@ Simple Ruuvitag is a hard frok and ***HEAVY*** simplification of [ttu work](http It uses bleson python lib, which leverages Python 3 native support for Bluetooth sockets. However, in order for python to have access to AF_SOCKET family, python needs to be compiled with lib-bluetooth or bluez. +**⚠️ This is a recent library with no guarantee of stability. There might be breaking changes so use the release tags to pull specific version** + + # Usage ## Simplest usage @@ -13,8 +18,8 @@ The client will keep the latest state of all sensors that have been polled. ```python macs = ['CC:2C:6A:1E:59:3D', 'DD:2C:6A:1E:59:3D'] -ruuvi_client = RuuviTagClient() -ruuvi_client.listen(mac_addresses=macs) +ruuvi_client = RuuviTagClient(mac_addresses=macs) +ruuvi_client.start() last_datas = ruuvi_client.get_current_datas() print(last_datas) @@ -36,8 +41,8 @@ def handle_callback(data): print("Data from %s: %s" % (data[0], data[1])) macs = ['CC:2C:6A:1E:59:3D', 'DD:2C:6A:1E:59:3D'] -ruuvi_client = RuuviTagClient() -ruuvi_client.listen(callback=handle_callback, mac_addresses=macs) +ruuvi_client = RuuviTagClient(callback=handle_callback, mac_addresses=macs) +ruuvi_client.start() ``` ## Continous use: @@ -45,7 +50,7 @@ Although the bluetooth observer is running, data might stop coming through. For this we have `ruuvi_client.rescan()` witch will restart the observer, and data should start flowing again. ``` -ruuvi_client.listen() +ruuvi_client.start() time.sleep(5) last_datas = ruuvi_client.get_current_datas() print(last_datas) diff --git a/simple_ruuvitag/adaptors/__init__.py b/simple_ruuvitag/adaptors/__init__.py index e69de29..c74f08c 100644 --- a/simple_ruuvitag/adaptors/__init__.py +++ b/simple_ruuvitag/adaptors/__init__.py @@ -0,0 +1,11 @@ + +class BluetoothAdaptor(object): + + def __init__(self, callback, bt_device=''): + ''' + Arguments: + callback: Function that receives the data from BLE + device (string): BLE device (default 0) + ''' + + self.callback = callback diff --git a/simple_ruuvitag/adaptors/bleson.py b/simple_ruuvitag/adaptors/bleson.py index 1bd0ade..549c961 100644 --- a/simple_ruuvitag/adaptors/bleson.py +++ b/simple_ruuvitag/adaptors/bleson.py @@ -1,48 +1,41 @@ -import time import logging +from simple_ruuvitag.adaptors import BluetoothAdaptor from bleson import get_provider, Observer log = logging.getLogger(__name__) -class BlesonClient(object): +class BlesonClient(BluetoothAdaptor): '''Bluetooth LE communication with Bleson''' - def __init__(self): - self.observer = None - self.callback = None - - - def handle_callback(self, advertisment): + def handle_callback(self, advertisement): - if not advertisment.mfg_data: + if not advertisement.mfg_data: # No data to return return processed_data = { - "address": advertisment.address.address, - "raw_data": "FF" + advertisment.mfg_data.hex(), + "address": advertisement.address.address, + "raw_data": advertisement.mfg_data.hex(), # these are documented but don't work - # "tx_power": data.tx_power, - # "rssi": data.rssi, - # "name": data.name, + # "tx_power": advertisement.tx_power, + # "rssi": advertisement.rssi, + # "name": advertisement.name, } - self.callback(processed_data) - def start(self): - if not self.observer: - log.info('Cannot start a client that has not been setup') - return - self.observer.start() + if processed_data["raw_data"][0:2] != 'FF': + processed_data["raw_data"] = 'FF' + processed_data["raw_data"] - def setup(self, callback, bt_device=''): + self.callback(processed_data) + + def __init__(self, callback, bt_device=''): ''' - Attributes: - callback: Function that recieves the data from BLE + Arguments: + callback: Function that receives the data from BLE device (string): BLE device (default 0) ''' + super().__init__(callback, bt_device) - # set callback - self.callback = callback + self.observer = None if not bt_device: bt_device = 0 @@ -55,7 +48,12 @@ def setup(self, callback, bt_device=''): adapter = get_provider().get_adapter(int(bt_device)) self.observer = Observer(adapter) self.observer.on_advertising_data = self.handle_callback - return self.observer + + def start(self): + if not self.observer: + log.info('Cannot start a client that has not been setup') + return + self.observer.start() def stop(self): self.observer.stop() diff --git a/simple_ruuvitag/adaptors/dummy.py b/simple_ruuvitag/adaptors/dummy.py index 13fd3a2..70f8184 100644 --- a/simple_ruuvitag/adaptors/dummy.py +++ b/simple_ruuvitag/adaptors/dummy.py @@ -1,25 +1,34 @@ +import logging +from simple_ruuvitag.adaptors import BluetoothAdaptor +log = logging.getLogger(__name__) -class DummyBle(object): +class DummyBle(BluetoothAdaptor): '''Bluetooth LE communication with Bleson''' def mock_datas(self, callback): - - callback(('DU:MM:YD:AT:A9:3D', - '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD')) - - callback(('NO:TS:UP:PO:RT:ED', - '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD')) - def __init__(self): - self.observer = None + callback( + { + "address": 'DU:MM:YD:AT:A9:3D', + "raw_data": '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD' + } + ) - def start(self, callback, bt_device=''): + callback( + { + "address": 'NO:TS:UP:PO:RT:ED', + "raw_data": '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD' + } + ) + + def start(self): # Simulates the call to the callback - self.mock_datas(callback) + self.mock_datas(self.callback) return None def stop(self): - pass \ No newline at end of file + # dummy BLE cannot stop. + pass diff --git a/simple_ruuvitag/decoder.py b/simple_ruuvitag/decoder.py index 4e13757..732dfd4 100644 --- a/simple_ruuvitag/decoder.py +++ b/simple_ruuvitag/decoder.py @@ -16,7 +16,17 @@ def get_decoder(data_type): Returns: object: Data decoder """ + if data_type == 2: + log.warning("DATA TYPE 2 IS OBSOLETE. UPDATE YOUR TAG") + # https://github.com/ruuvi/ruuvi-sensor-protocols/blob/master/dataformat_04.md + return UrlDecoder() + if data_type == 4: + log.warning("DATA TYPE 4 IS OBSOLETE. UPDATE YOUR TAG") + # https://github.com/ruuvi/ruuvi-sensor-protocols/blob/master/dataformat_04.md + return UrlDecoder() if data_type == 3: + log.warning("DATA TYPE 3 IS DEPRECATED - UPDATE YOUR TAG") + # https://github.com/ruuvi/ruuvi-sensor-protocols/blob/master/dataformat_03.md return Df3Decoder() return Df5Decoder() @@ -34,6 +44,68 @@ def rshift(val, n): """ return (val % 0x100000000) >> n + +class UrlDecoder(object): + """ + Decodes data from RuuviTag url + Protocol specification: + https://github.com/ruuvi/ruuvi-sensor-protocols + + Decoder operations are ported from: + https://github.com/ruuvi/sensor-protocol-for-eddystone-url/blob/master/index.html + + 0: uint8_t format; // (0x02 = realtime sensor readings) + 1: uint8_t humidity; // one lsb is 0.5% + 2-3: uint16_t temperature; // Signed 8.8 fixed-point notation. + 4-5: uint16_t pressure; // (-50kPa) + 6-7: uint16_t time; // seconds (now from reset) + + The bytes for temperature, pressure and time are swaped during the encoding + """ + + def _get_temperature(self, decoded): + """Return temperature in celsius""" + temp = (decoded[2] & 127) + decoded[3] / 100 + sign = (decoded[2] >> 7) & 1 + if sign == 0: + return round(temp, 2) + return round(-1 * temp, 2) + + def _get_humidity(self, decoded): + """Return humidity %""" + return decoded[1] * 0.5 + + def _get_pressure(self, decoded): + """Return air pressure hPa""" + pres = ((decoded[4] << 8) + decoded[5]) + 50000 + return pres / 100 + + def decode_data(self, encoded): + """ + Decode sensor data. + + Returns: + dict: Sensor values + """ + try: + identifier = None + data_format = 2 + if len(encoded) > 8: + data_format = 4 + identifier = encoded[8:] + encoded = encoded[:8] + decoded = bytearray(base64.b64decode(encoded, '-_')) + return { + 'data_format': data_format, + 'temperature': self._get_temperature(decoded), + 'humidity': self._get_humidity(decoded), + 'pressure': self._get_pressure(decoded), + 'identifier': identifier + } + except: + log.exception('Encoded value: %s not valid', encoded) + return None + class Df3Decoder(object): """ Decodes data from RuuviTag with Data Format 3 diff --git a/simple_ruuvitag/ruuvi.py b/simple_ruuvitag/ruuvi.py index 3fc606d..b2eea0b 100644 --- a/simple_ruuvitag/ruuvi.py +++ b/simple_ruuvitag/ruuvi.py @@ -1,5 +1,5 @@ import os -import datetime +from datetime import datetime import logging from simple_ruuvitag.data_formats import DataFormats @@ -14,35 +14,27 @@ class RuuviTagClient(object): """ RuuviTag communication functionality """ - - def __init__(self, adapter='bleson'): + def __init__(self, callback=log.info, mac_addresses=None, + bt_device=None, adapter='bleson'): if os.environ.get('CI') == 'True': - log.warn("Adapter override to Dummy due to CI env variable") - self.ble = DummyBle() + log.info("Adapter override to Dummy due to CI env variable") + self.ble_adaptor = DummyBle if adapter == 'dummy': - self.ble = DummyBle() + self.ble_adaptor = DummyBle elif adapter == 'bleson': - self.ble = BlesonClient() + self.ble_adaptor = BlesonClient else: raise RuntimeError("Unsupported adapter %s" % adapter) + # Setup defaults self.mac_blacklist = [] self.callback = print self.mac_addresses = None - self.latest_data = {} - - # Use this if you want to start listening right away - # example: bt_device='hci0' (defaults to device 0) - def listen(self, callback=log.info, mac_addresses=None, bt_device=None): - self.setup(callback=log.info, mac_addresses=mac_addresses, bt_device=bt_device) - self.start() - # Use this if you want to setup but wait before you start scanning - # example: bt_device='hci0' (defaults to device 0) - def setup(self, callback=log.info, mac_addresses=None, bt_device=None): + self.latest_data = {} if mac_addresses: if isinstance(mac_addresses, list): self.mac_addresses = [x.upper() for x in mac_addresses] @@ -50,7 +42,9 @@ def setup(self, callback=log.info, mac_addresses=None, bt_device=None): self.mac_addresses = mac_addresses.upper() self.callback = callback - self.ble.setup(self.convert_data_and_callback, bt_device=bt_device) + self.ble = self.ble_adaptor( + self.convert_data_and_callback, bt_device=bt_device + ) def resume(self): self.ble.start() @@ -59,7 +53,7 @@ def start(self): self.ble.start() def rescan(self): - self.ble.stop() + self.ble.stop() self.ble.start() def stop(self): @@ -71,7 +65,7 @@ def pause(self): def get_current_datas(self, consume=False): """ Get current data gets the current state of the known tags. - If consume=True it will delete the current data so that old + If consume=True it will delete the current data so that old readings don't get interpreted as current readings. """ return_data = self.latest_data.copy() @@ -88,32 +82,31 @@ def convert_data_and_callback(self, data): # { # "address": "MAC ADDRESS IN UPPERCASE" - # "raw_data": - # "rssi": - # "tx_power" - # "name": st + # "raw_data": xxxx # } mac_address = data["address"] raw_data = data["raw_data"] if mac_address in self.mac_blacklist: - log.debug("Skipping blacklisted mac %s" % mac_address) + log.debug("Skipping blacklisted mac %s", mac_address) return if self.mac_addresses and mac_address not in self.mac_addresses: - log.debug("Skipping non selected mac %s" % mac_address) + log.debug("Skipping non selected mac %s", mac_address) return (data_format, data) = DataFormats.convert_data(raw_data) - + print (data_format) if data is not None: state = get_decoder(data_format).decode_data(data) if state is not None: self.latest_data[mac_address] = state - self.latest_data[mac_address]['_updated_at'] = datetime.datetime.now() + self.latest_data[mac_address]['_updated_at'] = datetime.now() self.callback(mac_address, state) else: - log.error('Decoded data is null. MAC: %s - Raw: %s', mac_address, raw_data) + log.error( + 'Null decoded data. %s - raw: %s', mac_address, raw_data + ) else: - self.mac_blacklist.append(mac_address) \ No newline at end of file + self.mac_blacklist.append(mac_address) diff --git a/test_harness.py b/test_harness.py index b590764..2e8de08 100644 --- a/test_harness.py +++ b/test_harness.py @@ -1,20 +1,14 @@ import time from simple_ruuvitag.ruuvi import RuuviTagClient -macs = ['CD:81:78:21:E0:81'] ruuvi_client = RuuviTagClient() -# ruuvi_client.listen(mac_addresses=macs) -ruuvi_client.listen() - +ruuvi_client.start() time.sleep(5) last_datas = ruuvi_client.get_current_datas() - print(last_datas) - ruuvi_client.rescan() time.sleep(5) last_datas = ruuvi_client.get_current_datas() print(last_datas) - time.sleep(5) diff --git a/tests/test_ruuvitag.py b/tests/test_ruuvitag.py index d819083..e7f693f 100644 --- a/tests/test_ruuvitag.py +++ b/tests/test_ruuvitag.py @@ -10,14 +10,38 @@ class TestRuuviTag(TestCase): def mock_callbacks(self, callback): datas = [ - ('AA:2C:6A:1E:59:3D', '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD'), - ('BB:2C:6A:1E:59:3D', 'some other device'), - ('CC:2C:6A:1E:59:3D', '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD'), - ('DD:2C:6A:1E:59:3D', '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD'), - ('EE:2C:6A:1E:59:3D', '1F0201060303AAFE1716AAFE10F9037275752E76692F23416A5558314D417730C3'), - ('FF:2C:6A:1E:59:3D', '1902010415FF990403291A1ECE1E02DEF94202CA0B5300000000BB'), - ('00:2C:6A:1E:59:3D', '1902010415FF990403291A1ECE1E02DEF94202CA0B53BB'), - ('11:2C:6A:1E:59:3D', '043E2B020100014F884C33B8CB1F0201061BFF99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884FC4') + { + "address": 'AA:2C:6A:1E:59:3D', + "raw_data": '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD', + }, + { + "address": 'BB:2C:6A:1E:59:3D', + "raw_data": 'some other device', + }, + { + "address": 'CC:2C:6A:1E:59:3D', + "raw_data": '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD', + }, + { + "address": 'DD:2C:6A:1E:59:3D', + "raw_data": '1E0201060303AAFE1616AAFE10EE037275752E76692F23416A7759414D4663CD', + }, + { + "address": 'EE:2C:6A:1E:59:3D', + "raw_data": '1F0201060303AAFE1716AAFE10F9037275752E76692F23416A5558314D417730C3', + }, + { + "address": 'FF:2C:6A:1E:59:3D', + "raw_data": '1902010415FF990403291A1ECE1E02DEF94202CA0B5300000000BB', + }, + { + "address": '00:2C:6A:1E:59:3D', + "raw_data": '1902010415FF990403291A1ECE1E02DEF94202CA0B53BB', + }, + { + "address": '11:2C:6A:1E:59:3D', + "raw_data": '043E2B020100014F884C33B8CB1F0201061BFF99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884FC4' + }, ] for data in datas: @@ -27,8 +51,8 @@ def mock_callbacks(self, callback): def test_get_current_datas(self): macs = ['CC:2C:6A:1E:59:3D', 'DD:2C:6A:1E:59:3D', 'EE:2C:6A:1E:59:3D'] - ble_client = RuuviTagClient(adapter='dummy') - ble_client.listen(mac_addresses=macs) + ble_client = RuuviTagClient(mac_addresses=macs, adapter='dummy') + ble_client.start() data = ble_client.get_current_datas() @@ -46,8 +70,8 @@ def test_get_current_datas(self): def test_get_current_datas_without_filters(self): macs = None - ble_client = RuuviTagClient(adapter='dummy') - ble_client.listen(mac_addresses=macs) + ble_client = RuuviTagClient(mac_addresses=macs, adapter='dummy') + ble_client.start() data = ble_client.get_current_datas() @@ -57,7 +81,7 @@ def test_get_current_datas_without_filters(self): def test_get_current_datas_with_consume(self): ble_client = RuuviTagClient(adapter='dummy') - ble_client.listen() + ble_client.start() ble_client.get_current_datas(consume=True) mew_data = ble_client.get_current_datas(consume=True) @@ -67,6 +91,6 @@ def test_get_current_datas_with_consume(self): def test_blackisting_of_other_macs(self): ble_client = RuuviTagClient(adapter='dummy') - ble_client.listen() + ble_client.start() self.assertTrue('BB:2C:6A:1E:59:3D' in ble_client.mac_blacklist)