wpilgri
wpilgri

Reputation: 15

Asyncio, Arduino BLE, and not reading characteristic updates

I have an Arduino 33 BLE that is updating a few bluetooth characteristics with a string representation of BNO055 sensor calibration and quaternion data. On the Arduino side, I see the calibration and quaternion data getting updated in a nice orderly sequence as expected.

I have a Python (3.9) program running on Windows 10 that uses asyncio to subscribe to the characteristics on the Arduino to read the updates. Everything works fine when I have an update rate on the Arduino of 1/second. By "works fine" I mean I see the orderly sequence of updates: quaternion, calibration, quaternion, calibration,.... The problem I have is that I changed the update rate to the 10/second (100ms delay in Arduino) and now I am getting, for example, 100 updates for quaternion data but only 50 updates for calibration data when the number of updates should be equal. Somehow I'm not handling the updates properly on the python side.

The python code is listed below:

import asyncio
import pandas as pd

from bleak import BleakClient
from bleak import BleakScanner

ardAddress = ''
found = ''
exit_flag = False

temperaturedata = []
timedata = []
calibrationdata=[]
quaterniondata=[]

# loop: asyncio.AbstractEventLoop

tempServiceUUID = '0000290c-0000-1000-8000-00805f9b34fb'  # Temperature Service UUID on Arduino 33 BLE

stringUUID = '00002a56-0000-1000-8000-00805f9b34fb'  # Characteristic of type String [Write to Arduino]
inttempUUID = '00002a1c-0000-1000-8000-00805f9b34fb'  # Characteristic of type Int [Temperature]
longdateUUID = '00002a08-0000-1000-8000-00805f9b34fb'  # Characteristic of type Long [datetime millis]

strCalibrationUUID = '00002a57-0000-1000-8000-00805f9b34fb'  # Characteristic of type String [BNO055 Calibration]
strQuaternionUUID = '9e6c967a-5a87-49a1-a13f-5a0f96188552'  # Characteristic of type Long [BNO055 Quaternion]


async def scanfordevices():
    devices = await BleakScanner.discover()
    for d in devices:
        print(d)
        if (d.name == 'TemperatureMonitor'):
            global found, ardAddress
            found = True
            print(f'{d.name=}')
            print(f'{d.address=}')
            ardAddress = d.address
            print(f'{d.rssi=}')
            return d.address


async def readtemperaturecharacteristic(client, uuid: str):
    val = await client.read_gatt_char(uuid)
    intval = int.from_bytes(val, byteorder='little')
    print(f'readtemperaturecharacteristic:  Value read from: {uuid} is:  {val} | as int={intval}')


async def readdatetimecharacteristic(client, uuid: str):
    val = await client.read_gatt_char(uuid)
    intval = int.from_bytes(val, byteorder='little')
    print(f'readdatetimecharacteristic:  Value read from: {uuid} is:  {val} | as int={intval}')


async def readcalibrationcharacteristic(client, uuid: str):
    # Calibration characteristic is a string
    val = await client.read_gatt_char(uuid)
    strval = val.decode('UTF-8')
    print(f'readcalibrationcharacteristic:  Value read from: {uuid} is:  {val} | as string={strval}')


async def getservices(client):
    svcs = await client.get_services()
    print("Services:")
    for service in svcs:
        print(service)

        ch = service.characteristics
        for c in ch:
            print(f'\tCharacteristic Desc:{c.description} | UUID:{c.uuid}')


def notification_temperature_handler(sender, data):
    """Simple notification handler which prints the data received."""
    intval = int.from_bytes(data, byteorder='little')
    # TODO:  review speed of append vs extend.  Extend using iterable but is faster
    temperaturedata.append(intval)
    #print(f'Temperature:  Sender: {sender}, and byte data= {data} as an Int={intval}')


def notification_datetime_handler(sender, data):
    """Simple notification handler which prints the data received."""
    intval = int.from_bytes(data, byteorder='little')
    timedata.append(intval)
    #print(f'Datetime: Sender: {sender}, and byte data= {data} as an Int={intval}')


def notification_calibration_handler(sender, data):
    """Simple notification handler which prints the data received."""
    strval = data.decode('UTF-8')
    numlist=extractvaluesaslist(strval,':')
    #Save to list for processing later
    calibrationdata.append(numlist)

    print(f'Calibration Data: {sender}, and byte data= {data} as a List={numlist}')


def notification_quaternion_handler(sender, data):
    """Simple notification handler which prints the data received."""
    strval = data.decode('UTF-8')
    numlist=extractvaluesaslist(strval,':')

    #Save to list for processing later
    quaterniondata.append(numlist)

    print(f'Quaternion Data: {sender}, and byte data= {data} as a List={numlist}')


def extractvaluesaslist(raw, separator=':'):
    # Get everything after separator
    s1 = raw.split(sep=separator)[1]
    s2 = s1.split(sep=',')
    return list(map(float, s2))


async def runmain():
    # Based on code from: https://github.com/hbldh/bleak/issues/254
    global exit_flag

    print('runmain: Starting Main Device Scan')

    await scanfordevices()

    print('runmain: Scan is done, checking if found Arduino')

    if found:
        async with BleakClient(ardAddress) as client:

            print('runmain: Getting Service Info')
            await getservices(client)

            # print('runmain: Reading from Characteristics Arduino')
            # await readdatetimecharacteristic(client, uuid=inttempUUID)
            # await readcalibrationcharacteristic(client, uuid=strCalibrationUUID)

            print('runmain: Assign notification callbacks')
            await client.start_notify(inttempUUID, notification_temperature_handler)
            await client.start_notify(longdateUUID, notification_datetime_handler)
            await client.start_notify(strCalibrationUUID, notification_calibration_handler)
            await client.start_notify(strQuaternionUUID, notification_quaternion_handler)

            while not exit_flag:
                await asyncio.sleep(1)
            # TODO:  This does nothing.  Understand why?
            print('runmain: Stopping notifications.')
            await client.stop_notify(inttempUUID)
            print('runmain: Write to characteristic to let it know we plan to quit.')
            await client.write_gatt_char(stringUUID, 'Stopping'.encode('ascii'))
    else:
        print('runmain: Arduino not found.  Check that its on')

    print('runmain: Done.')


def main():
    # get main event loop
    loop = asyncio.get_event_loop()

    try:
        loop.run_until_complete(runmain())
    except KeyboardInterrupt:
        global exit_flag
        print('\tmain: Caught keyboard interrupt in main')
        exit_flag = True
    finally:
        pass

    print('main: Getting all pending tasks')

    # From book Pg 26.
    pending = asyncio.all_tasks(loop=loop)
    print(f'\tmain: number of tasks={len(pending)}')
    for task in pending:
        task.cancel()
    group = asyncio.gather(*pending, return_exceptions=True)
    print('main: Waiting for tasks to complete')
    loop.run_until_complete(group)
    loop.close()

    # Display data recorded in Dataframe
    if len(temperaturedata)==len(timedata):
        print(f'Temperature data len={len(temperaturedata)}, and len of timedata={len(timedata)}')

        df = pd.DataFrame({'datetime': timedata,
                           'temperature': temperaturedata})
        #print(f'dataframe shape={df.shape}')
        #print(df)
        df.to_csv('temperaturedata.csv')
    else:
        print(f'No data or lengths different: temp={len(temperaturedata)}, time={len(timedata)}')

    if len(quaterniondata)==len(calibrationdata):
        print('Processing Quaternion and Calibration Data')
        #Load quaternion data
        dfq=pd.DataFrame(quaterniondata,columns=['time','qw','qx','qy','qz'])
        print(f'Quaternion dataframe shape={dfq.shape}')
        #Add datetime millis data
        #dfq.insert(0,'Time',timedata)
        #Load calibration data
        dfcal=pd.DataFrame(calibrationdata,columns=['time','syscal','gyrocal','accelcal','magcal'])
        print(f'Calibration dataframe shape={dfcal.shape}')
        #Merge two dataframes together
        dffinal=pd.concat([dfq,dfcal],axis=1)
        dffinal.to_csv('quaternion_and_cal_data.csv')
    else:
        print(f'No data or lengths different. Quat={len(quaterniondata)}, Cal={len(calibrationdata)}')
        if len(quaterniondata)>0:
            dfq = pd.DataFrame(quaterniondata, columns=['time', 'qw', 'qx', 'qy', 'qz'])
            dfq.to_csv('quaterniononly.csv')
        if len(calibrationdata)>0:
            dfcal = pd.DataFrame(calibrationdata, columns=['time','syscal', 'gyrocal', 'accelcal', 'magcal'])
            dfcal.to_csv('calibrationonly.csv')

    print("main: Done.")


if __name__ == "__main__":
    '''Starting Point of Program'''
    main()

So, my first question is can anyone help me understand why I do not seem to be getting all the updates in my Python program? I should be seeing notification_quaternion_handler() and notification_calibration_handler() called the same number of times but I am not. I assume I am not using asyncio properly but I am at a loss to debug it at this point?

My second question is, are there best practices for trying to receive relatively high frequency updates from bluetooth, for example every 10-20 ms? I am trying to read IMU sensor data and it needs to be done at a fairly high rate.

This is my first attempt at bluetooth and asyncio so clearly I have a lot to learn.

Thank You for the help

Upvotes: 0

Views: 833

Answers (2)

wpilgri
wpilgri

Reputation: 15

Fantastic answer by @ukBaz.

In summary for other who may have a similar issue.

On the Arduino side I ended up with something like this (important parts only shown):

typedef struct  __attribute__ ((packed)) {
  unsigned long timeread;
  int qw; //float Quaternion values will be scaled to int by multiplying by constant
  int qx;
  int qy;
  int qz;
  uint8_t cal_system;
  uint8_t cal_gyro;
  uint8_t cal_accel;
  uint8_t cal_mag;
}sensordata ;

//Declare struct and populate
  sensordata datareading;

  datareading.timeread=tnow;
  datareading.qw=(int) (quat.w()*10000);
  datareading.qx=(int) (quat.x()*10000);
  datareading.qy=(int) (quat.y()*10000);
  datareading.qz=(int) (quat.z()*10000);
  datareading.cal_system=system;
  datareading.cal_gyro=gyro;
  datareading.cal_accel=accel;
  datareading.cal_mag=mag;

  //Write values to Characteristics.
  
  structDataChar.writeValue((uint8_t *)&datareading, sizeof(datareading));

Then on the Python (Windows Desktop) side I have this to unpack the data being sent:

def notification_structdata_handler(sender, data):
    """Simple notification handler which prints the data received."""
    
    # NOTE:  IT IS CRITICAL THAT THE UNPACK BYTE STRUCTURE MATCHES THE STRUCT
    #        CONFIGURATION SHOWN IN THE ARDUINO C PROGRAM.
    
    # <hh meaning:  <=little endian, h=short (2 bytes), b=1 byte, i=int 4 bytes, unsigned long = 4 bytes

    #Scale factor used in Arduino to convert floats to ints.
    scale=10000

    # Main Sensor struct
    t,qw,qx,qy,qz,cs,cg,ca,cm= struct.unpack('<5i4b', data)

    sensorstructdata.append([t,qw/scale,qx/scale,qy/scale,qz/scale,cs,cg,ca,cm])

    print(f'--->Struct Decoded. time={t}, qw={qw/scale}, qx={qx/scale}, qy={qy/scale}, qz={qz/scale},'
          f'cal_s={cs}, cal_g={cg}, cal_a={ca}, cal_m={cm}')

Thanks for all the help and as promised the performance is MUCH better than what I started with!

Upvotes: 1

ukBaz
ukBaz

Reputation: 7954

You have multiple characteristics that are being updated at the same frequency. It is more efficient in Bluetooth Low Energy (BLE) to transmit those values in the same characteristic. The other thing I noticed is that you appear to be sending the value as a string. It looks like the string format might "key:value" by the way you are extracting information from the string. This is also inefficient way to send data via BLE.

The data that is transmitted over BLE is always a list of bytes so if a float is required, it needs to be changed into an integer to be sent as bytes. As an example, if we wanted to send a value with two decimal places, multiplying it by 100 would always remove the decimal places. To go the other way it would be divide by 100. e.g:

>>> value = 12.34
>>> send = int(value * 100)
>>> print(send)
1234
>>> send / 100
12.34

The struct library allows integers to be easily packed that into a series of byes to send. As an example:

>>> import struct
>>> value1 = 12.34
>>> value2 = 67.89
>>> send_bytes = struct.pack('<hh', int(value1 * 100), int(value2 * 100))
>>> print(send_bytes)
b'\xd2\x04\x85\x1a'

To then unpack that:

>>> r_val1, r_val2 = struct.unpack('<hh', send_bytes)
>>> print(f'Value1={r_val1/100} : Value2={r_val2/100}')
Value1=12.34 : Value2=67.89

Using a single characteristic with the minimum number of bytes being transmitted should allow for the faster notifications.

To look at how other characteristics do this then look at the following document from the Bluetooth SIG: https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-5/

A good example might be the Blood Pressure Measurement characteristic.

Upvotes: 0

Related Questions