Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add BLE support #289

Open
wants to merge 25 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dc619ca
First BLE client structure and new BT client parent class
SonjaSt May 22, 2023
48d40e4
Added connect, read, disconnect, send to BLE client
SonjaSt May 22, 2023
49bb736
read binary file with BLE board info
salman2135 Oct 18, 2023
bd375f4
add ble to list to interfaes
salman2135 Dec 20, 2023
e8c4bbd
Merge branch 'APIS-813-BLE-client' into APIS-865-Update-BLE-packets
salman2135 Dec 20, 2023
083e9d8
add support for ble interface
salman2135 Dec 22, 2023
a747555
add BLE write feature
salman2135 Jan 8, 2024
99cb787
update byte order and remove gain in exg
salman2135 Jan 10, 2024
5c00065
add missing async keyword
salman2135 Jan 15, 2024
09554e7
write commands via stream loop
salman2135 Jan 16, 2024
abb7581
update setup.py and min python version
salman2135 Jan 16, 2024
47689d8
exit BLE context manager on disconnect
salman2135 Jan 25, 2024
60fe6f9
catch runtime exception in asyncio
salman2135 Jan 25, 2024
d355eea
check data variable for disconnection
salman2135 Jan 26, 2024
ac2cb8e
bin2csv for 32 channel with new packets
salman2135 Feb 12, 2024
4aa3dd7
impedance measurement for BLE 8 ch
salman2135 Feb 21, 2024
5ab0263
Merge branch 'develop' into APIS-865-Update-BLE-packets
salman2135 Feb 27, 2024
1376458
fix flake8 errors
salman2135 Feb 27, 2024
2fb3ee7
improve disconnection, add gain calculation
salman2135 Feb 28, 2024
24ed257
continue parsing on fletcher error for BLE in bin2csv
salman2135 Apr 4, 2024
b31b629
skip packet when payload is wrong
salman2135 Apr 4, 2024
cca3bf1
device name based bt backend detection
salman2135 Apr 5, 2024
fb7fc43
continue parsing on assertion error
salman2135 Apr 8, 2024
1128630
visualizer with gaps filled with duplicate filtered packets
salman2135 Apr 8, 2024
6d8a6df
remove prints
salman2135 Apr 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 3 additions & 4 deletions setup.py
Expand Up @@ -42,7 +42,9 @@ def read(*names, **kwargs):
'eeglabio',
'pandas',
'pyserial',
'pyyaml'] # noqa: E501
'pyyaml',
'bleak'] # noqa: E501

test_requirements = ["pytest==6.2.5",
"pytest-mock==3.10.0",
"pytest-html==3.2.0",
Expand Down Expand Up @@ -106,9 +108,6 @@ def read(*names, **kwargs):
'Operating System :: POSIX',
'Operating System :: Microsoft :: Windows',
'Programming Language :: Python',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Topic :: Education',
Expand Down
12 changes: 4 additions & 8 deletions src/explorepy/__init__.py
Expand Up @@ -23,12 +23,9 @@
__version__ = '2.0.0'

this = sys.modules[__name__]
list_sdk = ['sdk', 'mock', 'pyserial']
if sys.platform == 'darwin':
this._bt_interface = 'pyserial'
else:
from . import exploresdk
this._bt_interface = 'sdk'
# TODO appropriate library
bt_interface_list = ['sdk', 'ble', 'mock', 'pyserial']
this._bt_interface = 'ble'

if not sys.version_info >= (3, 6):
raise EnvironmentError("Explorepy requires python versions 3.6 or newer!")
Expand All @@ -44,8 +41,7 @@ def set_bt_interface(bt_interface):
bt_interface (str): Bluetooth interface type. Options:'sdk'

"""

if bt_interface not in list_sdk:
if bt_interface not in bt_interface_list:
raise ValueError(("Invalid Bluetooth interface: " + bt_interface))

this._bt_interface = bt_interface
Expand Down
206 changes: 202 additions & 4 deletions src/explorepy/btcpp.py
@@ -1,7 +1,17 @@
# -*- coding: utf-8 -*-
"""A module for bluetooth connection"""
import abc
import asyncio
import atexit
import logging
import threading
import time
from queue import Queue

from bleak import (
BleakClient,
BleakScanner
)

from explorepy import (
exploresdk,
Expand All @@ -16,8 +26,68 @@
logger = logging.getLogger(__name__)


class SDKBtClient:
class BTClient(abc.ABC):
@abc.abstractmethod
def __init__(self, device_name=None, mac_address=None):
if (mac_address is None) and (device_name is None):
raise InputError("Either name or address options must be provided!")
self.is_connected = False
self.mac_address = mac_address
self.device_name = device_name
self.bt_serial_port_manager = None
self.device_manager = None

@abc.abstractmethod
def connect(self):
"""Connect to the device and return the socket

Returns:
socket (bluetooth.socket)
"""

@abc.abstractmethod
def reconnect(self):
"""Reconnect to the last used bluetooth socket.

This function reconnects to the last bluetooth socket. If after 1 minute the connection doesn't succeed,
program will end.
"""

@abc.abstractmethod
def disconnect(self):
"""Disconnect from the device"""

@abc.abstractmethod
def _find_mac_address(self):
pass

@abc.abstractmethod
def read(self, n_bytes):
"""Read n_bytes from the socket

Args:
n_bytes (int): number of bytes to be read

Returns:
list of bytes
"""

@abc.abstractmethod
def send(self, data):
"""Send data to the device

Args:
data (bytearray): Data to be sent
"""

@staticmethod
def _check_mac_address(device_name, mac_address):
return (device_name[-4:-2] == mac_address[-5:-3]) and (device_name[-2:] == mac_address[-2:])


class SDKBtClient(BTClient):
""" Responsible for Connecting and reconnecting explore devices via bluetooth"""

def __init__(self, device_name=None, mac_address=None):
"""Initialize Bluetooth connection

Expand Down Expand Up @@ -170,6 +240,134 @@ def send(self, data):
"""
self.bt_serial_port_manager.Write(data)

@staticmethod
def _check_mac_address(device_name, mac_address):
return (device_name[-4:-2] == mac_address[-5:-3]) and (device_name[-2:] == mac_address[-2:])

class BLEClient(BTClient):
""" Responsible for Connecting and reconnecting explore devices via bluetooth"""

def __init__(self, device_name=None, mac_address=None):
"""Initialize Bluetooth connection

Args:
device_name(str): Name of the device (either device_name or device address should be given)
mac_address(str): Devices MAC address
"""
super().__init__(device_name=device_name, mac_address=mac_address)

self.ble_device = None
self.eeg_service_uuid = "FFFE0001-B5A3-F393-E0A9-E50E24DCCA9E"
self.eeg_tx_char_uuid = "FFFE0003-B5A3-F393-E0A9-E50E24DCCA9E"
self.eeg_rx_char_uuid = "FFFE0002-B5A3-F393-E0A9-E50E24DCCA9E"
self.rx_char = None
self.buffer = Queue()
self.try_disconnect = False
self.notification_thread = None
self.copy_buffer = bytearray()
self.read_event = asyncio.Event()
self.data = None

async def stream(self):

async with BleakClient(self.ble_device) as client:
def handle_packet(sender, bt_byte_array):
# write packet to buffer
self.buffer.put(bt_byte_array)

await client.start_notify(self.eeg_tx_char_uuid, handle_packet)
loop = asyncio.get_running_loop()
self.rx_char = client.services.get_service(self.eeg_service_uuid).get_characteristic(self.eeg_rx_char_uuid)
while True:
loop.run_in_executor(None, await self.read_event.wait())
if self.data is None:
print('Client disconnection requested')
self.is_connected = False
break
await client.write_gatt_char(self.rx_char, self.data, response=False)
self.data = None
self.read_event.clear()

def connect(self):
"""Connect to the device and return the socket

Returns:
socket (bluetooth.socket)
"""
asyncio.run(self._discover_device())
if self.ble_device is None:
print('No device found!!')
else:
logger.info('Device is connected')
self.is_connected = True
self.notification_thread = threading.Thread(target=self.start_read_loop, daemon=True)
self.notification_thread.start()
atexit.register(self.disconnect)

def start_read_loop(self):
try:
asyncio.run(self.stream())
except RuntimeError as error:
logger.info('Shutting down BLE stream loop with error {}'.format(error))

def stop_read_loop(self):
print('calling stop!!')
self.notification_thread.join()

async def _discover_device(self):
if self.mac_address:
self.ble_device = await BleakScanner.find_device_by_address(self.mac_address)
else:
logger.info('Commencing device discovery')
self.ble_device = await BleakScanner.find_device_by_name(self.device_name, timeout=15)

if self.ble_device is None:
print('No device found!!!!!')
raise DeviceNotFoundError(
"Could not discover the device! Please make sure the device is on and in advertising mode."
)

def reconnect(self):
"""Reconnect to the last used bluetooth socket.

This function reconnects to the last bluetooth socket. If after 1 minute the connection doesn't succeed,
program will end.
"""
self.connect()

def disconnect(self):
"""Disconnect from the device"""
self.read_event.set()
self.stop_read_loop()

def _find_mac_address(self):
raise NotImplementedError

def read(self, n_bytes):
"""Read n_bytes from the socket

Args:
n_bytes (int): number of bytes to be read

Returns:
list of bytes
"""
try:
if len(self.copy_buffer) < n_bytes:
get_item = self.buffer.get()
self.copy_buffer.extend(get_item)
ret = self.copy_buffer[:n_bytes]
self.copy_buffer = self.copy_buffer[n_bytes:]
if len(ret) < n_bytes:
raise ConnectionAbortedError('Error reading data from BLE stream, too many bytes requested')
return ret
except Exception as error:
logger.error('Unknown error reading data from BLE stream')
raise ConnectionAbortedError(str(error))

def send(self, data):
"""Send data to the device

Args:
data (bytearray): Data to be sent
"""
self.data = data
print('sending data to device')
self.read_event.set()
5 changes: 3 additions & 2 deletions src/explorepy/explore.py
Expand Up @@ -87,14 +87,13 @@ def connect(self, device_name=None, mac_address=None):
while "adc_mask" not in self.stream_processor.device_info:
logger.info("Waiting for device info packet...")
time.sleep(1)
if cnt >= 10:
if cnt >= 100:
raise ConnectionAbortedError("Could not get info packet from the device")
cnt += 1

logger.info('Device info packet has been received. Connection has been established. Streaming...')
logger.info("Device info: " + str(self.stream_processor.device_info))
self.is_connected = True
self.stream_processor.send_timestamp()
if self.debug:
self.stream_processor.subscribe(callback=self.debug.process_bin, topic=TOPICS.packet_bin)

Expand Down Expand Up @@ -257,6 +256,8 @@ def convert_bin(self, bin_file, out_dir='', file_type='edf', do_overwrite=False,
self.mask = [1 for _ in range(0, 32)]
if 'PCB_305_801_XXX' in self.stream_processor.device_info['board_id']:
self.mask = [1 for _ in range(0, 16)]
if 'PCB_304_801p2_X' in self.stream_processor.device_info['board_id']:
self.mask = [1 for _ in range(0, 32)]

self.recorders['exg'] = create_exg_recorder(filename=exg_out_file,
file_type=self.recorders['file_type'],
Expand Down
45 changes: 40 additions & 5 deletions src/explorepy/packet.py
Expand Up @@ -13,7 +13,7 @@

logger = logging.getLogger(__name__)

TIMESTAMP_SCALE = 10000
TIMESTAMP_SCALE = 100000


class PACKET_ID(IntEnum):
Expand All @@ -30,6 +30,8 @@ class PACKET_ID(IntEnum):
EEG98 = 146
EEG32 = 148
EEG98_USBC = 150
EEG98_BLE = 151
EEG32_BLE = 152
EEG99 = 62
EEG94R = 208
EEG98R = 210
Expand Down Expand Up @@ -84,7 +86,7 @@ def int24to32(bin_data):
"""
assert len(bin_data) % 3 == 0, "Packet length error!"
return np.asarray([
int.from_bytes(bin_data[x:x + 3], byteorder="little", signed=True)
int.from_bytes(bin_data[x:x + 3], byteorder="big", signed=True)
for x in range(0, len(bin_data), 3)
])

Expand Down Expand Up @@ -124,9 +126,7 @@ def _convert(self, bin_data):
n_chan = -1
data = data.reshape((self.n_packet, n_chan)).astype(float).T
gain = EXG_UNIT * ((2 ** 23) - 1) * 6.0
self.data = np.round(data[1:, :] * self.v_ref / gain, 2)
# EEG32: status bits will change in future releases as we need to use 4 bytes for 32 channel status
self.status = self.int32_to_status(data[0, :])
self.data = np.round(data * self.v_ref / gain, 2)

@staticmethod
def int32_to_status(data):
Expand Down Expand Up @@ -203,6 +203,20 @@ def __init__(self, timestamp, payload, time_offset=0):
super().__init__(timestamp, payload, time_offset, v_ref=2.4, n_packet=16)


class EEG98_BLE(EEG):
"""EEG packet for 8 channel device"""

def __init__(self, timestamp, payload, time_offset=0):
super().__init__(timestamp, payload, time_offset, v_ref=2.4, n_packet=1)


class EEG32_BLE(EEG):
"""EEG packet for 32 channel BLE device"""

def __init__(self, timestamp, payload, time_offset=0):
super().__init__(timestamp, payload, time_offset, v_ref=2.4, n_packet=1)


class EEG99(EEG):
"""EEG packet for 8 channel device"""

Expand Down Expand Up @@ -559,6 +573,25 @@ def _convert(self, bin_data):
super()._convert(bin_data, offset_multiplier=0.01)


class BleImpedancePacket(EEG98_USBC):

def __init__(self, timestamp, payload, time_offset=0):
self.timestamp = timestamp

def _convert(self, bin_data):
pass

def populate_packet_with_data(self, ble_packet_list):
data_array = None
for i in range(len(ble_packet_list)):
_, data = ble_packet_list[i].get_data()
if data_array is None:
data_array = data
else:
data_array = np.concatenate((data_array, data), axis=1)
self.data = data_array


PACKET_CLASS_DICT = {
PACKET_ID.ORN: Orientation,
PACKET_ID.ENV: Environment,
Expand All @@ -572,6 +605,8 @@ def _convert(self, bin_data):
PACKET_ID.EEG94R: EEG94,
PACKET_ID.EEG98R: EEG98,
PACKET_ID.EEG98_USBC: EEG98_USBC,
PACKET_ID.EEG98_BLE: EEG98_BLE,
PACKET_ID.EEG32_BLE: EEG32_BLE,
PACKET_ID.EEG32: EEG32,
PACKET_ID.CMDRCV: CommandRCV,
PACKET_ID.CMDSTAT: CommandStatus,
Expand Down